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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/checkmark.pngbin0 -> 596 bytes
-rw-r--r--app/assets/images/chevron-down.pngbin0 -> 599 bytes
-rw-r--r--app/assets/images/jobs-empty-state.svg33
-rw-r--r--app/assets/javascripts/admin/users/components/app.vue26
-rw-r--r--app/assets/javascripts/admin/users/components/users_table.vue63
-rw-r--r--app/assets/javascripts/admin/users/index.js22
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue19
-rw-r--r--app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue146
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue4
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue (renamed from app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue)189
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue494
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue82
-rw-r--r--app/assets/javascripts/alerts_settings/services/index.js21
-rw-r--r--app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql4
-rw-r--r--app/assets/javascripts/api.js19
-rw-r--r--app/assets/javascripts/authentication/mount_2fa.js10
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue174
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/constants.js11
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/index.js46
-rw-r--r--app/assets/javascripts/autosave.js1
-rw-r--r--app/assets/javascripts/awards_handler.js1
-rw-r--r--app/assets/javascripts/badges/components/badge.vue2
-rw-r--r--app/assets/javascripts/behaviors/select2.js33
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js1
-rw-r--r--app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue2
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js7
-rw-r--r--app/assets/javascripts/blob/file_template_selector.js15
-rw-r--r--app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue2
-rw-r--r--app/assets/javascripts/blob/template_selector.js11
-rw-r--r--app/assets/javascripts/blob/viewer/index.js6
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js22
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js13
-rw-r--r--app/assets/javascripts/boards/boards_util.js77
-rw-r--r--app/assets/javascripts/boards/components/board_assignee_dropdown.vue106
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue17
-rw-r--r--app/assets/javascripts/boards/components/board_column_new.vue30
-rw-r--r--app/assets/javascripts/boards/components/board_configuration_options.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue66
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue228
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue14
-rw-r--r--app/assets/javascripts/boards/components/board_list_header_new.vue109
-rw-r--r--app/assets/javascripts/boards/components/board_list_new.vue145
-rw-r--r--app/assets/javascripts/boards/components/board_promotion_state.js1
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue2
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue83
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue25
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js1
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue3
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_editable_item.vue15
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_due_date.vue2
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue3
-rw-r--r--app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue161
-rw-r--r--app/assets/javascripts/boards/constants.js6
-rw-r--r--app/assets/javascripts/boards/ee_functions.js2
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js25
-rw-r--r--app/assets/javascripts/boards/graphql/board.fragment.graphql (renamed from app/assets/javascripts/boards/queries/board.fragment.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/board.mutation.graphql (renamed from app/assets/javascripts/boards/queries/board.mutation.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/board_labels.query.graphql (renamed from app/assets/javascripts/boards/queries/board_labels.query.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/board_list.fragment.graphql (renamed from app/assets/javascripts/boards/queries/board_list.fragment.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql (renamed from app/assets/javascripts/boards/queries/board_list_create.mutation.graphql)2
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql (renamed from app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql (renamed from app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql (renamed from app/assets/javascripts/boards/queries/board_list_update.mutation.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/board_lists.query.graphql (renamed from app/assets/javascripts/boards/queries/board_lists.query.graphql)2
-rw-r--r--app/assets/javascripts/boards/graphql/group_boards.query.graphql (renamed from app/assets/javascripts/boards/queries/group_boards.query.graphql)2
-rw-r--r--app/assets/javascripts/boards/graphql/group_milestones.query.graphql17
-rw-r--r--app/assets/javascripts/boards/graphql/issue.fragment.graphql (renamed from app/assets/javascripts/boards/queries/issue.fragment.graphql)4
-rw-r--r--app/assets/javascripts/boards/graphql/issue_create.mutation.graphql (renamed from app/assets/javascripts/boards/queries/issue_create.mutation.graphql)2
-rw-r--r--app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql (renamed from app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql)2
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql (renamed from app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql (renamed from app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql12
-rw-r--r--app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql (renamed from app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql)0
-rw-r--r--app/assets/javascripts/boards/graphql/lists_issues.query.graphql (renamed from app/assets/javascripts/boards/queries/lists_issues.query.graphql)2
-rw-r--r--app/assets/javascripts/boards/graphql/project_boards.query.graphql (renamed from app/assets/javascripts/boards/queries/project_boards.query.graphql)2
-rw-r--r--app/assets/javascripts/boards/graphql/users_search.query.graphql (renamed from app/assets/javascripts/boards/queries/users_search.query.graphql)0
-rw-r--r--app/assets/javascripts/boards/index.js49
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js5
-rw-r--r--app/assets/javascripts/boards/stores/actions.js126
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js82
-rw-r--r--app/assets/javascripts/boards/stores/getters.js9
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js12
-rw-r--r--app/assets/javascripts/boards/stores/state.js3
-rw-r--r--app/assets/javascripts/ci_lint/components/ci_lint.vue7
-rw-r--r--app/assets/javascripts/ci_lint/graphql/resolvers.js34
-rw-r--r--app/assets/javascripts/ci_lint/index.js3
-rw-r--r--app/assets/javascripts/clone_panel.js42
-rw-r--r--app/assets/javascripts/close_reopen_report_toggle.js92
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js15
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue51
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue9
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue2
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js6
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js27
-rw-r--r--app/assets/javascripts/commit/image_file.js16
-rw-r--r--app/assets/javascripts/commits.js6
-rw-r--r--app/assets/javascripts/commons/bootstrap.js9
-rw-r--r--app/assets/javascripts/confirm_danger_modal.js2
-rw-r--r--app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue1
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue24
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/actions.js8
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue4
-rw-r--r--app/assets/javascripts/create_label.js6
-rw-r--r--app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue1
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js1
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js1
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue13
-rw-r--r--app/assets/javascripts/design_management/utils/tracking.js29
-rw-r--r--app/assets/javascripts/diffs/components/app.vue56
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue7
-rw-r--r--app/assets/javascripts/diffs/components/compare_dropdown_layout.vue85
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue26
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue24
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue3
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_row.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue12
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue28
-rw-r--r--app/assets/javascripts/diffs/components/image_diff_overlay.vue45
-rw-r--r--app/assets/javascripts/diffs/components/merge_conflict_warning.vue8
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue41
-rw-r--r--app/assets/javascripts/diffs/constants.js6
-rw-r--r--app/assets/javascripts/diffs/i18n.js4
-rw-r--r--app/assets/javascripts/diffs/index.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js159
-rw-r--r--app/assets/javascripts/diffs/store/getters.js48
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js3
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js105
-rw-r--r--app/assets/javascripts/diffs/store/utils.js196
-rw-r--r--app/assets/javascripts/diffs/utils/diff_file.js (renamed from app/assets/javascripts/diffs/diff_file.js)24
-rw-r--r--app/assets/javascripts/diffs/utils/preferences.js22
-rw-r--r--app/assets/javascripts/due_date_select.js21
-rw-r--r--app/assets/javascripts/editor/constants.js4
-rw-r--r--app/assets/javascripts/editor/editor_file_template_ext.js7
-rw-r--r--app/assets/javascripts/editor/editor_lite.js34
-rw-r--r--app/assets/javascripts/editor/editor_lite_extension_base.js11
-rw-r--r--app/assets/javascripts/editor/editor_markdown_ext.js14
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue67
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue121
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue31
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue31
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue17
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue19
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags.vue18
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_tab.vue10
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue5
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue3
-rw-r--r--app/assets/javascripts/feature_flags/components/new_feature_flag.vue28
-rw-r--r--app/assets/javascripts/feature_flags/components/strategy.vue4
-rw-r--r--app/assets/javascripts/feature_flags/constants.js4
-rw-r--r--app/assets/javascripts/filterable_list.js6
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js14
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js5
-rw-r--r--app/assets/javascripts/filtered_search/constants.js2
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js10
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_storage_keys.js2
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue2
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue12
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue11
-rw-r--r--app/assets/javascripts/frequent_items/index.js5
-rw-r--r--app/assets/javascripts/frequent_items/store/index.js7
-rw-r--r--app/assets/javascripts/frequent_items/store/state.js3
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js1
-rw-r--r--app/assets/javascripts/gl_field_error.js2
-rw-r--r--app/assets/javascripts/gl_form.js4
-rw-r--r--app/assets/javascripts/graphql_shared/queries/users_search.query.graphql9
-rw-r--r--app/assets/javascripts/graphql_shared/utils.js38
-rw-r--r--app/assets/javascripts/groups/components/group_folder.vue2
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue30
-rw-r--r--app/assets/javascripts/groups/components/visibility_level_dropdown.vue48
-rw-r--r--app/assets/javascripts/groups/index.js3
-rw-r--r--app/assets/javascripts/groups/members/components/app.vue10
-rw-r--r--app/assets/javascripts/groups/members/index.js21
-rw-r--r--app/assets/javascripts/groups/members/utils.js5
-rw-r--r--app/assets/javascripts/groups/store/groups_store.js5
-rw-r--r--app/assets/javascripts/groups/store/utils.js27
-rw-r--r--app/assets/javascripts/groups/visibility_level.js24
-rw-r--r--app/assets/javascripts/groups_select.js178
-rw-r--r--app/assets/javascripts/header.js2
-rw-r--r--app/assets/javascripts/helpers/issuables_helper.js27
-rw-r--r--app/assets/javascripts/how_to_merge.js15
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue21
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue4
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue71
-rw-r--r--app/assets/javascripts/ide/components/file_templates/dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue40
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue6
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue16
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue2
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown.vue3
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue1
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue11
-rw-r--r--app/assets/javascripts/ide/components/preview/clientside.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue20
-rw-r--r--app/assets/javascripts/ide/components/terminal/session.vue18
-rw-r--r--app/assets/javascripts/ide/components/terminal/view.vue3
-rw-r--r--app/assets/javascripts/ide/ide_router.js16
-rw-r--r--app/assets/javascripts/ide/index.js5
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions.js21
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js23
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js21
-rw-r--r--app/assets/javascripts/ide/utils.js4
-rw-r--r--app/assets/javascripts/import_entities/components/import_status.vue (renamed from app/assets/javascripts/import_projects/components/import_status.vue)0
-rw-r--r--app/assets/javascripts/import_entities/constants.js (renamed from app/assets/javascripts/import_projects/constants.js)0
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue78
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue106
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js95
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql8
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql3
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql3
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql3
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql6
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql7
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js45
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js68
-rw-r--r--app/assets/javascripts/import_entities/import_groups/index.js31
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue (renamed from app/assets/javascripts/import_projects/components/bitbucket_status_table.vue)0
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue (renamed from app/assets/javascripts/import_projects/components/import_projects_table.vue)6
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue (renamed from app/assets/javascripts/import_projects/components/provider_repo_table_row.vue)4
-rw-r--r--app/assets/javascripts/import_entities/import_projects/index.js (renamed from app/assets/javascripts/import_projects/index.js)11
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/actions.js (renamed from app/assets/javascripts/import_projects/store/actions.js)0
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/getters.js (renamed from app/assets/javascripts/import_projects/store/getters.js)2
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/index.js (renamed from app/assets/javascripts/import_projects/store/index.js)0
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutation_types.js (renamed from app/assets/javascripts/import_projects/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutations.js (renamed from app/assets/javascripts/import_projects/store/mutations.js)2
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/state.js (renamed from app/assets/javascripts/import_projects/store/state.js)0
-rw-r--r--app/assets/javascripts/import_entities/import_projects/utils.js (renamed from app/assets/javascripts/import_projects/utils.js)2
-rw-r--r--app/assets/javascripts/importer_status.js149
-rw-r--r--app/assets/javascripts/incidents_settings/components/alerts_form.vue4
-rw-r--r--app/assets/javascripts/incidents_settings/components/pagerduty_form.vue31
-rw-r--r--app/assets/javascripts/incidents_settings/constants.js16
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue30
-rw-r--r--app/assets/javascripts/integrations/edit/store/actions.js21
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/integrations/edit/store/mutations.js6
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js6
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js14
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js1
-rw-r--r--app/assets/javascripts/issuable_context.js14
-rw-r--r--app/assets/javascripts/issuable_create/components/issuable_create_root.vue2
-rw-r--r--app/assets/javascripts/issuable_form.js64
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue180
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_body.vue20
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_edit_form.vue45
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_header.vue2
-rw-r--r--app/assets/javascripts/issuable_show/components/issuable_show_root.vue22
-rw-r--r--app/assets/javascripts/issue.js137
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue10
-rw-r--r--app/assets/javascripts/issue_show/components/header_actions.vue55
-rw-r--r--app/assets/javascripts/issue_show/utils/parse_data.js8
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue13
-rw-r--r--app/assets/javascripts/issues_list/components/issuables_list_app.vue1
-rw-r--r--app/assets/javascripts/jira_connect.js63
-rw-r--r--app/assets/javascripts/jira_connect/components/app.vue13
-rw-r--r--app/assets/javascripts/jira_connect/index.js74
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue15
-rw-r--r--app/assets/javascripts/jobs/components/log/line.vue65
-rw-r--r--app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue27
-rw-r--r--app/assets/javascripts/jobs/mixins/delayed_job_mixin.js7
-rw-r--r--app/assets/javascripts/jobs/store/actions.js35
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js1
-rw-r--r--app/assets/javascripts/jobs/utils.js14
-rw-r--r--app/assets/javascripts/labels_select.js21
-rw-r--r--app/assets/javascripts/layout_nav.js29
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js53
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js8
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js1
-rw-r--r--app/assets/javascripts/lib/utils/scroll_utils.js2
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js3
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js10
-rw-r--r--app/assets/javascripts/logs/utils.js2
-rw-r--r--app/assets/javascripts/main.js9
-rw-r--r--app/assets/javascripts/maintenance_mode_settings/components/app.vue44
-rw-r--r--app/assets/javascripts/maintenance_mode_settings/index.js20
-rw-r--r--app/assets/javascripts/members.js2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue)0
-rw-r--r--app/assets/javascripts/members/components/action_buttons/action_button_group.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue)0
-rw-r--r--app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue)0
-rw-r--r--app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue)0
-rw-r--r--app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue)0
-rw-r--r--app/assets/javascripts/members/components/action_buttons/leave_button.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue)2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue)0
-rw-r--r--app/assets/javascripts/members/components/action_buttons/remove_member_button.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue)0
-rw-r--r--app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue)0
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue (renamed from app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue)2
-rw-r--r--app/assets/javascripts/members/components/avatars/group_avatar.vue (renamed from app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue)2
-rw-r--r--app/assets/javascripts/members/components/avatars/invite_avatar.vue (renamed from app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue)2
-rw-r--r--app/assets/javascripts/members/components/avatars/user_avatar.vue (renamed from app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue)4
-rw-r--r--app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue26
-rw-r--r--app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue132
-rw-r--r--app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue77
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue (renamed from app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue)2
-rw-r--r--app/assets/javascripts/members/components/modals/remove_group_link_modal.vue (renamed from app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue)2
-rw-r--r--app/assets/javascripts/members/components/table/created_at.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/created_at.vue)0
-rw-r--r--app/assets/javascripts/members/components/table/expiration_datepicker.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue)0
-rw-r--r--app/assets/javascripts/members/components/table/expires_at.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/expires_at.vue)2
-rw-r--r--app/assets/javascripts/members/components/table/member_action_buttons.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue)2
-rw-r--r--app/assets/javascripts/members/components/table/member_avatar.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue)0
-rw-r--r--app/assets/javascripts/members/components/table/member_source.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/member_source.vue)0
-rw-r--r--app/assets/javascripts/members/components/table/members_table.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/members_table.vue)15
-rw-r--r--app/assets/javascripts/members/components/table/members_table_cell.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue)11
-rw-r--r--app/assets/javascripts/members/components/table/role_dropdown.vue (renamed from app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue)3
-rw-r--r--app/assets/javascripts/members/constants.js (renamed from app/assets/javascripts/vue_shared/components/members/constants.js)29
-rw-r--r--app/assets/javascripts/members/store/actions.js (renamed from app/assets/javascripts/vuex_shared/modules/members/actions.js)0
-rw-r--r--app/assets/javascripts/members/store/index.js9
-rw-r--r--app/assets/javascripts/members/store/mutation_types.js (renamed from app/assets/javascripts/vuex_shared/modules/members/mutation_types.js)0
-rw-r--r--app/assets/javascripts/members/store/mutations.js (renamed from app/assets/javascripts/vuex_shared/modules/members/mutations.js)0
-rw-r--r--app/assets/javascripts/members/store/state.js (renamed from app/assets/javascripts/vuex_shared/modules/members/state.js)6
-rw-r--r--app/assets/javascripts/members/store/utils.js (renamed from app/assets/javascripts/vuex_shared/modules/members/utils.js)0
-rw-r--r--app/assets/javascripts/members/utils.js97
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js1
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js2
-rw-r--r--app/assets/javascripts/merge_request.js12
-rw-r--r--app/assets/javascripts/merge_request_tabs.js3
-rw-r--r--app/assets/javascripts/milestone_select.js21
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js1
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js5
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue1
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue4
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js2
-rw-r--r--app/assets/javascripts/monitoring/utils.js2
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue11
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue4
-rw-r--r--app/assets/javascripts/notebook/lib/highlight.js19
-rw-r--r--app/assets/javascripts/notes.js1
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue137
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue22
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_utils.js10
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue7
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue9
-rw-r--r--app/assets/javascripts/notes/constants.js15
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js9
-rw-r--r--app/assets/javascripts/notes/stores/actions.js37
-rw-r--r--app/assets/javascripts/notes/stores/collapse_utils.js3
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js8
-rw-r--r--app/assets/javascripts/packages/details/components/app.vue79
-rw-r--r--app/assets/javascripts/packages/details/components/package_files.vue107
-rw-r--r--app/assets/javascripts/packages/details/components/package_history.vue121
-rw-r--r--app/assets/javascripts/packages/details/constants.js2
-rw-r--r--app/assets/javascripts/packages/list/constants.js4
-rw-r--r--app/assets/javascripts/packages/shared/constants.js1
-rw-r--r--app/assets/javascripts/packages/shared/utils.js3
-rw-r--r--app/assets/javascripts/pager.js1
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue18
-rw-r--r--app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue71
-rw-r--r--app/assets/javascripts/pages/admin/users/index.js14
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js62
-rw-r--r--app/assets/javascripts/pages/import/bitbucket/status/index.js4
-rw-r--r--app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue2
-rw-r--r--app/assets/javascripts/pages/import/bitbucket_server/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/bulk_imports/index.js4
-rw-r--r--app/assets/javascripts/pages/import/fogbugz/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/gitea/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/github/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/gitlab/status/index.js2
-rw-r--r--app/assets/javascripts/pages/import/manifest/status/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/accounts/show/index.js3
-rw-r--r--app/assets/javascripts/pages/profiles/two_factor_auths/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js56
-rw-r--r--app/assets/javascripts/pages/projects/commit/pipelines/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js52
-rw-r--r--app/assets/javascripts/pages/projects/commits/show/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue1
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js3
-rw-r--r--app/assets/javascripts/pages/projects/jobs/index/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js2
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js52
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/index/index.js61
-rw-r--r--app/assets/javascripts/pages/projects/project.js40
-rw-r--r--app/assets/javascripts/pages/projects/settings/access_tokens/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue6
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue42
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js4
-rw-r--r--app/assets/javascripts/pages/search/show/index.js4
-rw-r--r--app/assets/javascripts/pages/search/show/search.js65
-rw-r--r--app/assets/javascripts/performance/constants.js21
-rw-r--r--app/assets/javascripts/performance_bar/index.js19
-rw-r--r--app/assets/javascripts/persistent_user_callouts.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue139
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue (renamed from app/assets/javascripts/ci_lint/components/ci_lint_results.vue)2
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue (renamed from app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue)0
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue (renamed from app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue)0
-rw-r--r--app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue (renamed from app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue)0
-rw-r--r--app/assets/javascripts/pipeline_editor/components/text_editor.vue14
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql26
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql (renamed from app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql)0
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql11
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js31
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js12
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue246
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue164
-rw-r--r--app/assets/javascripts/pipeline_new/constants.js3
-rw-r--r--app/assets/javascripts/pipeline_new/index.js12
-rw-r--r--app/assets/javascripts/pipeline_new/utils/format_refs.js18
-rw-r--r--app/assets/javascripts/pipelines/components/graph/accessors.js25
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue270
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue265
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue106
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue70
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue139
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue87
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue118
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue108
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js57
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue7
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js10
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue76
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue109
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue13
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue43
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/unwrapping_utils.js53
-rw-r--r--app/assets/javascripts/pipelines/constants.js2
-rw-r--r--app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql17
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql65
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql1
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql20
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_width_mixin.js50
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_graph.js38
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js75
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/actions.js1
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/getters.js7
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js1
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/mutations.js8
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/state.js4
-rw-r--r--app/assets/javascripts/pipelines/utils.js87
-rw-r--r--app/assets/javascripts/project_find_file.js1
-rw-r--r--app/assets/javascripts/project_select.js196
-rw-r--r--app/assets/javascripts/project_select_combo_button.js12
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/projects/default_sample_data_templates.js12
-rw-r--r--app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue6
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue234
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue151
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue7
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/constants.js6
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql14
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql17
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js63
-rw-r--r--app/assets/javascripts/projects/project_new.js7
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js7
-rw-r--r--app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue79
-rw-r--r--app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js21
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue5
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue6
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/index.js2
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/details_header.vue36
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue2
-rw-r--r--app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue14
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue5
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue5
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list.vue33
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue34
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue6
-rw-r--r--app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue21
-rw-r--r--app/assets/javascripts/registry/explorer/constants/details.js2
-rw-r--r--app/assets/javascripts/registry/explorer/constants/list.js5
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql11
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/index.js14
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql9
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql5
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql41
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql23
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql23
-rw-r--r--app/assets/javascripts/registry/explorer/index.js37
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue177
-rw-r--r--app/assets/javascripts/registry/explorer/pages/index.vue4
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue133
-rw-r--r--app/assets/javascripts/registry/explorer/router.js4
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js119
-rw-r--r--app/assets/javascripts/registry/explorer/stores/getters.js18
-rw-r--r--app/assets/javascripts/registry/explorer/stores/index.js16
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutation_types.js10
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutations.js54
-rw-r--r--app/assets/javascripts/registry/explorer/stores/state.js10
-rw-r--r--app/assets/javascripts/registry/explorer/utils.js25
-rw-r--r--app/assets/javascripts/registry/settings/components/expiration_dropdown.vue50
-rw-r--r--app/assets/javascripts/registry/settings/components/expiration_input.vue110
-rw-r--r--app/assets/javascripts/registry/settings/components/expiration_run_text.vue46
-rw-r--r--app/assets/javascripts/registry/settings/components/expiration_toggle.vue52
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue13
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue237
-rw-r--r--app/assets/javascripts/registry/settings/constants.js81
-rw-r--r--app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql1
-rw-r--r--app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql (renamed from app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql)0
-rw-r--r--app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql (renamed from app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql)0
-rw-r--r--app/assets/javascripts/registry/settings/graphql/utils/cache_update.js2
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js13
-rw-r--r--app/assets/javascripts/registry/settings/utils.js (renamed from app/assets/javascripts/registry/shared/utils.js)22
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue258
-rw-r--r--app/assets/javascripts/registry/shared/constants.js69
-rw-r--r--app/assets/javascripts/related_issues/components/issue_token.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issuable_input.vue2
-rw-r--r--app/assets/javascripts/related_issues/components/related_issues_root.vue11
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue8
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue30
-rw-r--r--app/assets/javascripts/reports/constants.js14
-rw-r--r--app/assets/javascripts/reports/store/utils.js13
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue2
-rw-r--r--app/assets/javascripts/right_sidebar.js3
-rw-r--r--app/assets/javascripts/search/group_filter/components/group_filter.vue124
-rw-r--r--app/assets/javascripts/search/group_filter/constants.js10
-rw-r--r--app/assets/javascripts/search/group_filter/index.js28
-rw-r--r--app/assets/javascripts/search/index.js4
-rw-r--r--app/assets/javascripts/search/sidebar/components/app.vue2
-rw-r--r--app/assets/javascripts/search/store/actions.js22
-rw-r--r--app/assets/javascripts/search/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/search/store/mutations.js11
-rw-r--r--app/assets/javascripts/search/store/state.js2
-rw-r--r--app/assets/javascripts/search/topbar/components/group_filter.vue49
-rw-r--r--app/assets/javascripts/search/topbar/components/project_filter.vue52
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown.vue144
-rw-r--r--app/assets/javascripts/search/topbar/constants.js21
-rw-r--r--app/assets/javascripts/search/topbar/index.js44
-rw-r--r--app/assets/javascripts/search_autocomplete.js4
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue5
-rw-r--r--app/assets/javascripts/settings_panels.js1
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue11
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue14
-rw-r--r--app/assets/javascripts/single_file_diff.js18
-rw-r--r--app/assets/javascripts/smart_interval.js1
-rw-r--r--app/assets/javascripts/sourcegraph/index.js2
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue6
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js11
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js13
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue5
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js43
-rw-r--r--app/assets/javascripts/terminal/terminal.js2
-rw-r--r--app/assets/javascripts/terraform/components/states_table.vue122
-rw-r--r--app/assets/javascripts/terraform/components/states_table_actions.vue192
-rw-r--r--app/assets/javascripts/terraform/components/terraform_list.vue58
-rw-r--r--app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql17
-rw-r--r--app/assets/javascripts/terraform/graphql/mutations/lock_state.mutation.graphql5
-rw-r--r--app/assets/javascripts/terraform/graphql/mutations/remove_state.mutation.graphql5
-rw-r--r--app/assets/javascripts/terraform/graphql/mutations/unlock_state.mutation.graphql5
-rw-r--r--app/assets/javascripts/terraform/index.js1
-rw-r--r--app/assets/javascripts/users_select/index.js238
-rw-r--r--app/assets/javascripts/version_check_image.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue172
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue134
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue30
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue28
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue39
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/callout.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_badge_link.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue142
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue18
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/dismissible_container.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js142
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue238
-rw-r--r--app/assets/javascripts/vue_shared/components/help_popover.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue61
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue59
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/members/utils.js48
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue3
-rw-r--r--app/assets/javascripts/vue_shared/directives/popover.js22
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/constants.js8
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue58
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue48
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue59
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js29
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql23
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/security_reports_app.vue275
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/constants.js7
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/getters.js66
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/index.js16
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/messages.js4
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/state.js5
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/utils.js78
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/utils.js22
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/index.js10
-rw-r--r--app/assets/javascripts/vulnerabilities/constants.js15
-rw-r--r--app/assets/javascripts/whats_new/components/app.vue130
-rw-r--r--app/assets/javascripts/whats_new/components/feature.vue67
-rw-r--r--app/assets/javascripts/whats_new/index.js20
-rw-r--r--app/assets/javascripts/whats_new/store/actions.js3
-rw-r--r--app/assets/javascripts/whats_new/utils/notification.js17
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/application.scss4
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss4
-rw-r--r--app/assets/stylesheets/components/milestone_combobox.scss11
-rw-r--r--app/assets/stylesheets/components/popover.scss111
-rw-r--r--app/assets/stylesheets/components/whats_new.scss26
-rw-r--r--app/assets/stylesheets/fontawesome_custom.scss208
-rw-r--r--app/assets/stylesheets/framework/animations.scss3
-rw-r--r--app/assets/stylesheets/framework/awards.scss108
-rw-r--r--app/assets/stylesheets/framework/blocks.scss2
-rw-r--r--app/assets/stylesheets/framework/buttons.scss47
-rw-r--r--app/assets/stylesheets/framework/common.scss5
-rw-r--r--app/assets/stylesheets/framework/diffs.scss24
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss59
-rw-r--r--app/assets/stylesheets/framework/forms.scss14
-rw-r--r--app/assets/stylesheets/framework/header.scss15
-rw-r--r--app/assets/stylesheets/framework/lists.scss2
-rw-r--r--app/assets/stylesheets/framework/mixins.scss18
-rw-r--r--app/assets/stylesheets/framework/modal.scss1
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss8
-rw-r--r--app/assets/stylesheets/framework/selects.scss288
-rw-r--r--app/assets/stylesheets/framework/tables.scss43
-rw-r--r--app/assets/stylesheets/framework/toggle.scss8
-rw-r--r--app/assets/stylesheets/framework/typography.scss11
-rw-r--r--app/assets/stylesheets/framework/variables.scss1
-rw-r--r--app/assets/stylesheets/lazy_bundles/select2_overrides.scss22
-rw-r--r--app/assets/stylesheets/page_bundles/_pipeline_mixins.scss26
-rw-r--r--app/assets/stylesheets/page_bundles/alert_management_details.scss23
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss8
-rw-r--r--app/assets/stylesheets/page_bundles/build.scss14
-rw-r--r--app/assets/stylesheets/page_bundles/ci_status.scss35
-rw-r--r--app/assets/stylesheets/page_bundles/cycle_analytics.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/import.scss81
-rw-r--r--app/assets/stylesheets/page_bundles/merge_conflicts.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/oncall_schedules.scss189
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss67
-rw-r--r--app/assets/stylesheets/page_bundles/pipelines.scss11
-rw-r--r--app/assets/stylesheets/page_bundles/profile_two_factor_auth.scss11
-rw-r--r--app/assets/stylesheets/pages/clusters.scss16
-rw-r--r--app/assets/stylesheets/pages/commits.scss6
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss6
-rw-r--r--app/assets/stylesheets/pages/editor.scss8
-rw-r--r--app/assets/stylesheets/pages/groups.scss12
-rw-r--r--app/assets/stylesheets/pages/import.scss61
-rw-r--r--app/assets/stylesheets/pages/issuable.scss49
-rw-r--r--app/assets/stylesheets/pages/issues.scss13
-rw-r--r--app/assets/stylesheets/pages/members.scss14
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss51
-rw-r--r--app/assets/stylesheets/pages/notes.scss24
-rw-r--r--app/assets/stylesheets/pages/notifications.scss6
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss5
-rw-r--r--app/assets/stylesheets/pages/projects.scss38
-rw-r--r--app/assets/stylesheets/pages/runners.scss18
-rw-r--r--app/assets/stylesheets/pages/search.scss9
-rw-r--r--app/assets/stylesheets/pages/settings.scss7
-rw-r--r--app/assets/stylesheets/pages/tree.scss7
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss4
-rw-r--r--app/assets/stylesheets/themes/_dark.scss116
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss29
-rw-r--r--app/assets/stylesheets/utilities.scss27
-rw-r--r--app/controllers/admin/cohorts_controller.rb2
-rw-r--r--app/controllers/admin/dashboard_controller.rb5
-rw-r--r--app/controllers/admin/instance_review_controller.rb2
-rw-r--r--app/controllers/admin/instance_statistics_controller.rb2
-rw-r--r--app/controllers/admin/integrations_controller.rb6
-rw-r--r--app/controllers/admin/users_controller.rb10
-rw-r--r--app/controllers/application_controller.rb7
-rw-r--r--app/controllers/boards/lists_controller.rb8
-rw-r--r--app/controllers/concerns/dependency_proxy/auth.rb43
-rw-r--r--app/controllers/concerns/dependency_proxy/group_access.rb26
-rw-r--r--app/controllers/concerns/dependency_proxy_access.rb24
-rw-r--r--app/controllers/concerns/integrations_actions.rb11
-rw-r--r--app/controllers/concerns/issuable_collections.rb2
-rw-r--r--app/controllers/concerns/service_params.rb3
-rw-r--r--app/controllers/concerns/snippets_actions.rb3
-rw-r--r--app/controllers/concerns/sorting_preference.rb27
-rw-r--r--app/controllers/concerns/wiki_actions.rb28
-rw-r--r--app/controllers/concerns/workhorse_authorization.rb (renamed from app/controllers/concerns/workhorse_import_export_upload.rb)20
-rw-r--r--app/controllers/dashboard/projects_controller.rb2
-rw-r--r--app/controllers/explore/projects_controller.rb2
-rw-r--r--app/controllers/graphql_controller.rb6
-rw-r--r--app/controllers/groups/application_controller.rb13
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/children_controller.rb10
-rw-r--r--app/controllers/groups/dependency_proxies_controller.rb2
-rw-r--r--app/controllers/groups/dependency_proxy_auth_controller.rb11
-rw-r--r--app/controllers/groups/dependency_proxy_for_containers_controller.rb9
-rw-r--r--app/controllers/groups/group_members_controller.rb4
-rw-r--r--app/controllers/groups/milestones_controller.rb3
-rw-r--r--app/controllers/groups/settings/integrations_controller.rb4
-rw-r--r--app/controllers/groups_controller.rb11
-rw-r--r--app/controllers/import/bulk_imports_controller.rb17
-rw-r--r--app/controllers/import/fogbugz_controller.rb8
-rw-r--r--app/controllers/import/gitea_controller.rb8
-rw-r--r--app/controllers/import/github_controller.rb22
-rw-r--r--app/controllers/import/gitlab_groups_controller.rb10
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb10
-rw-r--r--app/controllers/import/google_code_controller.rb123
-rw-r--r--app/controllers/invites_controller.rb14
-rw-r--r--app/controllers/jira_connect/app_descriptor_controller.rb71
-rw-r--r--app/controllers/jwt_controller.rb3
-rw-r--r--app/controllers/profiles/keys_controller.rb21
-rw-r--r--app/controllers/projects/alert_management_controller.rb2
-rw-r--r--app/controllers/projects/alerting/notifications_controller.rb4
-rw-r--r--app/controllers/projects/blob_controller.rb5
-rw-r--r--app/controllers/projects/boards_controller.rb2
-rw-r--r--app/controllers/projects/branches_controller.rb47
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb3
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb2
-rw-r--r--app/controllers/projects/feature_flags_controller.rb11
-rw-r--r--app/controllers/projects/issues_controller.rb23
-rw-r--r--app/controllers/projects/jobs_controller.rb23
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb22
-rw-r--r--app/controllers/projects/milestones_controller.rb3
-rw-r--r--app/controllers/projects/pipelines_controller.rb14
-rw-r--r--app/controllers/projects/prometheus/alerts_controller.rb4
-rw-r--r--app/controllers/projects/raw_controller.rb16
-rw-r--r--app/controllers/projects/runners_controller.rb15
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb1
-rw-r--r--app/controllers/projects/settings/operations_controller.rb1
-rw-r--r--app/controllers/projects/static_site_editor_controller.rb4
-rw-r--r--app/controllers/projects/wikis_controller.rb3
-rw-r--r--app/controllers/projects_controller.rb24
-rw-r--r--app/controllers/registrations/welcome_controller.rb2
-rw-r--r--app/controllers/registrations_controller.rb12
-rw-r--r--app/controllers/repositories/git_http_client_controller.rb6
-rw-r--r--app/controllers/repositories/git_http_controller.rb7
-rw-r--r--app/controllers/repositories/lfs_api_controller.rb22
-rw-r--r--app/controllers/search_controller.rb34
-rw-r--r--app/controllers/uploads_controller.rb8
-rw-r--r--app/controllers/users_controller.rb18
-rw-r--r--app/controllers/whats_new_controller.rb34
-rw-r--r--app/experiments/application_experiment.rb83
-rw-r--r--app/finders/alert_management/alerts_finder.rb7
-rw-r--r--app/finders/ci/daily_build_group_report_results_finder.rb13
-rw-r--r--app/finders/ci/pipelines_finder.rb12
-rw-r--r--app/finders/ci/pipelines_for_merge_request_finder.rb29
-rw-r--r--app/finders/feature_flags_finder.rb6
-rw-r--r--app/finders/group_descendants_finder.rb2
-rw-r--r--app/finders/group_members_finder.rb7
-rw-r--r--app/finders/issuable_finder.rb9
-rw-r--r--app/finders/issuable_finder/params.rb4
-rw-r--r--app/finders/members_finder.rb9
-rw-r--r--app/finders/merge_requests_finder.rb32
-rw-r--r--app/finders/merge_requests_finder/params.rb25
-rw-r--r--app/finders/releases/evidence_pipeline_finder.rb46
-rw-r--r--app/graphql/mutations/alert_management/base.rb2
-rw-r--r--app/graphql/mutations/alert_management/create_alert_issue.rb1
-rw-r--r--app/graphql/mutations/alert_management/http_integration/destroy.rb2
-rw-r--r--app/graphql/mutations/alert_management/http_integration/reset_token.rb2
-rw-r--r--app/graphql/mutations/alert_management/http_integration/update.rb2
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb2
-rw-r--r--app/graphql/mutations/alert_management/prometheus_integration/update.rb2
-rw-r--r--app/graphql/mutations/award_emojis/add.rb2
-rw-r--r--app/graphql/mutations/award_emojis/base.rb23
-rw-r--r--app/graphql/mutations/award_emojis/remove.rb2
-rw-r--r--app/graphql/mutations/award_emojis/toggle.rb2
-rw-r--r--app/graphql/mutations/boards/common_mutation_arguments.rb24
-rw-r--r--app/graphql/mutations/boards/create.rb26
-rw-r--r--app/graphql/mutations/boards/lists/create.rb20
-rw-r--r--app/graphql/mutations/boards/update.rb43
-rw-r--r--app/graphql/mutations/ci/base.rb2
-rw-r--r--app/graphql/mutations/concerns/mutations/finds_by_gid.rb9
-rw-r--r--app/graphql/mutations/container_repositories/destroy.rb13
-rw-r--r--app/graphql/mutations/container_repositories/destroy_base.rb18
-rw-r--r--app/graphql/mutations/container_repositories/destroy_tags.rb50
-rw-r--r--app/graphql/mutations/design_management/base.rb2
-rw-r--r--app/graphql/mutations/discussions/toggle_resolve.rb2
-rw-r--r--app/graphql/mutations/environments/canary_ingress/update.rb39
-rw-r--r--app/graphql/mutations/issues/update.rb2
-rw-r--r--app/graphql/mutations/merge_requests/base.rb2
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/create.rb4
-rw-r--r--app/graphql/mutations/metrics/dashboard/annotations/delete.rb2
-rw-r--r--app/graphql/mutations/notes/create/base.rb2
-rw-r--r--app/graphql/mutations/notes/create/note.rb2
-rw-r--r--app/graphql/mutations/notes/destroy.rb2
-rw-r--r--app/graphql/mutations/notes/reposition_image_diff_note.rb2
-rw-r--r--app/graphql/mutations/notes/update/base.rb2
-rw-r--r--app/graphql/mutations/releases/create.rb3
-rw-r--r--app/graphql/mutations/releases/delete.rb40
-rw-r--r--app/graphql/mutations/releases/update.rb70
-rw-r--r--app/graphql/mutations/snippets/create.rb31
-rw-r--r--app/graphql/mutations/snippets/destroy.rb2
-rw-r--r--app/graphql/mutations/snippets/mark_as_spam.rb4
-rw-r--r--app/graphql/mutations/snippets/update.rb8
-rw-r--r--app/graphql/mutations/todos/mark_done.rb2
-rw-r--r--app/graphql/mutations/todos/restore.rb2
-rw-r--r--app/graphql/mutations/todos/restore_many.rb4
-rw-r--r--app/graphql/queries/epic/epic_children.query.graphql126
-rw-r--r--app/graphql/queries/epic/epic_details.query.graphql20
-rw-r--r--app/graphql/resolvers/alert_management/alert_resolver.rb5
-rw-r--r--app/graphql/resolvers/assigned_merge_requests_resolver.rb1
-rw-r--r--app/graphql/resolvers/authored_merge_requests_resolver.rb1
-rw-r--r--app/graphql/resolvers/base_resolver.rb12
-rw-r--r--app/graphql/resolvers/board_list_issues_resolver.rb2
-rw-r--r--app/graphql/resolvers/board_lists_resolver.rb10
-rw-r--r--app/graphql/resolvers/ci/config_resolver.rb60
-rw-r--r--app/graphql/resolvers/ci/jobs_resolver.rb2
-rw-r--r--app/graphql/resolvers/ci/pipeline_stages_resolver.rb3
-rw-r--r--app/graphql/resolvers/ci/runner_setup_resolver.rb5
-rw-r--r--app/graphql/resolvers/concerns/caching_array_resolver.rb17
-rw-r--r--app/graphql/resolvers/concerns/manual_authorization.rb11
-rw-r--r--app/graphql/resolvers/design_management/design_resolver.rb2
-rw-r--r--app/graphql/resolvers/design_management/version/design_at_version_resolver.rb2
-rw-r--r--app/graphql/resolvers/design_management/version_in_collection_resolver.rb2
-rw-r--r--app/graphql/resolvers/design_management/versions_resolver.rb2
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb2
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb27
-rw-r--r--app/graphql/resolvers/group_members_resolver.rb5
-rw-r--r--app/graphql/resolvers/issue_status_counts_resolver.rb2
-rw-r--r--app/graphql/resolvers/issues_resolver.rb4
-rw-r--r--app/graphql/resolvers/members_resolver.rb4
-rw-r--r--app/graphql/resolvers/merge_request_pipelines_resolver.rb22
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb10
-rw-r--r--app/graphql/resolvers/project_members_resolver.rb5
-rw-r--r--app/graphql/resolvers/project_merge_requests_resolver.rb1
-rw-r--r--app/graphql/resolvers/project_pipeline_resolver.rb4
-rw-r--r--app/graphql/resolvers/project_pipeline_statistics_resolver.rb28
-rw-r--r--app/graphql/resolvers/projects/jira_imports_resolver.rb23
-rw-r--r--app/graphql/resolvers/projects/services_resolver.rb6
-rw-r--r--app/graphql/resolvers/review_requested_merge_requests_resolver.rb13
-rw-r--r--app/graphql/resolvers/snippets/blobs_resolver.rb6
-rw-r--r--app/graphql/resolvers/user_discussions_count_resolver.rb26
-rw-r--r--app/graphql/resolvers/user_notes_count_resolver.rb26
-rw-r--r--app/graphql/resolvers/users/group_count_resolver.rb2
-rw-r--r--app/graphql/resolvers/users_resolver.rb2
-rw-r--r--app/graphql/types/alert_management/domain_filter_enum.rb13
-rw-r--r--app/graphql/types/alert_management/prometheus_integration_type.rb2
-rw-r--r--app/graphql/types/award_emojis/award_emoji_type.rb9
-rw-r--r--app/graphql/types/base_field.rb7
-rw-r--r--app/graphql/types/base_interface.rb2
-rw-r--r--app/graphql/types/board_list_type.rb7
-rw-r--r--app/graphql/types/board_type.rb6
-rw-r--r--app/graphql/types/ci/analytics_type.rb33
-rw-r--r--app/graphql/types/ci/ci_cd_setting_type.rb20
-rw-r--r--app/graphql/types/ci/config/config_type.rb21
-rw-r--r--app/graphql/types/ci/config/group_type.rb19
-rw-r--r--app/graphql/types/ci/config/job_type.rb21
-rw-r--r--app/graphql/types/ci/config/need_type.rb15
-rw-r--r--app/graphql/types/ci/config/stage_type.rb17
-rw-r--r--app/graphql/types/ci/config/status_enum.rb15
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb30
-rw-r--r--app/graphql/types/ci/group_type.rb7
-rw-r--r--app/graphql/types/ci/job_artifact_file_type_enum.rb13
-rw-r--r--app/graphql/types/ci/job_artifact_type.rb24
-rw-r--r--app/graphql/types/ci/job_type.rb30
-rw-r--r--app/graphql/types/ci/pipeline_type.rb24
-rw-r--r--app/graphql/types/ci/stage_type.rb7
-rw-r--r--app/graphql/types/commit_type.rb11
-rw-r--r--app/graphql/types/concerns/gitlab_style_deprecations.rb4
-rw-r--r--app/graphql/types/container_repository_type.rb5
-rw-r--r--app/graphql/types/design_management/design_collection_type.rb2
-rw-r--r--app/graphql/types/error_tracking/sentry_error_collection_type.rb21
-rw-r--r--app/graphql/types/group_invitation_type.rb7
-rw-r--r--app/graphql/types/group_member_relation_enum.rb12
-rw-r--r--app/graphql/types/group_member_type.rb7
-rw-r--r--app/graphql/types/group_type.rb25
-rw-r--r--app/graphql/types/issue_type.rb26
-rw-r--r--app/graphql/types/jira_import_type.rb3
-rw-r--r--app/graphql/types/jira_users_mapping_input_type.rb2
-rw-r--r--app/graphql/types/merge_request_connection_type.rb15
-rw-r--r--app/graphql/types/merge_request_type.rb54
-rw-r--r--app/graphql/types/mutation_type.rb4
-rw-r--r--app/graphql/types/namespace_type.rb8
-rw-r--r--app/graphql/types/notes/diff_position_type.rb42
-rw-r--r--app/graphql/types/notes/note_type.rb14
-rw-r--r--app/graphql/types/permission_types/merge_request.rb4
-rw-r--r--app/graphql/types/project_member_relation_enum.rb12
-rw-r--r--app/graphql/types/project_type.rb71
-rw-r--r--app/graphql/types/query_type.rb10
-rw-r--r--app/graphql/types/snippets/blob_viewer_type.rb14
-rw-r--r--app/graphql/types/sort_enum.rb8
-rw-r--r--app/graphql/types/terraform/state_type.rb8
-rw-r--r--app/graphql/types/terraform/state_version_type.rb35
-rw-r--r--app/graphql/types/todo_type.rb21
-rw-r--r--app/graphql/types/tree/blob_type.rb11
-rw-r--r--app/graphql/types/tree/tree_type.rb31
-rw-r--r--app/graphql/types/user_type.rb12
-rw-r--r--app/helpers/admin/user_actions_helper.rb56
-rw-r--r--app/helpers/application_helper.rb8
-rw-r--r--app/helpers/application_settings_helper.rb10
-rw-r--r--app/helpers/auth_helper.rb4
-rw-r--r--app/helpers/blob_helper.rb14
-rw-r--r--app/helpers/button_helper.rb10
-rw-r--r--app/helpers/ci/pipeline_schedules_helper.rb15
-rw-r--r--app/helpers/ci/runners_helper.rb16
-rw-r--r--app/helpers/container_registry_helper.rb4
-rw-r--r--app/helpers/defer_script_tag_helper.rb10
-rw-r--r--app/helpers/diff_helper.rb8
-rw-r--r--app/helpers/dropdowns_helper.rb2
-rw-r--r--app/helpers/environment_helper.rb2
-rw-r--r--app/helpers/events_helper.rb2
-rw-r--r--app/helpers/form_helper.rb10
-rw-r--r--app/helpers/gitlab_script_tag_helper.rb24
-rw-r--r--app/helpers/groups/group_members_helper.rb3
-rw-r--r--app/helpers/groups_helper.rb5
-rw-r--r--app/helpers/icons_helper.rb20
-rw-r--r--app/helpers/issuables_helper.rb66
-rw-r--r--app/helpers/issues_helper.rb8
-rw-r--r--app/helpers/markup_helper.rb27
-rw-r--r--app/helpers/merge_requests_helper.rb21
-rw-r--r--app/helpers/notifications_helper.rb3
-rw-r--r--app/helpers/notify_helper.rb4
-rw-r--r--app/helpers/operations_helper.rb10
-rw-r--r--app/helpers/profiles_helper.rb6
-rw-r--r--app/helpers/projects/alert_management_helper.rb2
-rw-r--r--app/helpers/projects/terraform_helper.rb5
-rw-r--r--app/helpers/projects_helper.rb19
-rw-r--r--app/helpers/search_helper.rb4
-rw-r--r--app/helpers/services_helper.rb20
-rw-r--r--app/helpers/sorting_helper.rb367
-rw-r--r--app/helpers/sorting_titles_values_helper.rb339
-rw-r--r--app/helpers/storage_helper.rb6
-rw-r--r--app/helpers/suggest_pipeline_helper.rb4
-rw-r--r--app/helpers/system_note_helper.rb3
-rw-r--r--app/helpers/time_zone_helper.rb34
-rw-r--r--app/helpers/tree_helper.rb4
-rw-r--r--app/helpers/user_callouts_helper.rb5
-rw-r--r--app/helpers/users_helper.rb93
-rw-r--r--app/helpers/visibility_level_helper.rb10
-rw-r--r--app/helpers/web_ide_button_helper.rb12
-rw-r--r--app/helpers/whats_new_helper.rb20
-rw-r--r--app/mailers/emails/issues.rb10
-rw-r--r--app/mailers/emails/members.rb9
-rw-r--r--app/mailers/emails/profile.rb8
-rw-r--r--app/mailers/emails/service_desk.rb2
-rw-r--r--app/models/alert_management/alert.rb9
-rw-r--r--app/models/alert_management/http_integration.rb1
-rw-r--r--app/models/analytics/devops_adoption.rb6
-rw-r--r--app/models/analytics/devops_adoption/segment.rb24
-rw-r--r--app/models/analytics/devops_adoption/segment_selection.rb36
-rw-r--r--app/models/application_setting.rb57
-rw-r--r--app/models/application_setting_implementation.rb6
-rw-r--r--app/models/approval.rb2
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/bulk_imports/entity.rb25
-rw-r--r--app/models/bulk_imports/failure.rb13
-rw-r--r--app/models/ci/bridge.rb6
-rw-r--r--app/models/ci/build.rb55
-rw-r--r--app/models/ci/build_dependencies.rb82
-rw-r--r--app/models/ci/build_trace_chunks/fog.rb29
-rw-r--r--app/models/ci/job_artifact.rb8
-rw-r--r--app/models/ci/pipeline.rb130
-rw-r--r--app/models/clusters/agent.rb4
-rw-r--r--app/models/clusters/applications/helm.rb35
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb4
-rw-r--r--app/models/clusters/concerns/application_core.rb8
-rw-r--r--app/models/clusters/concerns/application_data.rb8
-rw-r--r--app/models/clusters/platforms/kubernetes.rb66
-rw-r--r--app/models/commit_collection.rb6
-rw-r--r--app/models/concerns/cache_markdown_field.rb62
-rw-r--r--app/models/concerns/can_move_repository_storage.rb46
-rw-r--r--app/models/concerns/case_sensitivity.rb12
-rw-r--r--app/models/concerns/enums/ci/pipeline.rb9
-rw-r--r--app/models/concerns/enums/data_visualization_palette.rb33
-rw-r--r--app/models/concerns/enums/internal_id.rb3
-rw-r--r--app/models/concerns/has_repository.rb2
-rw-r--r--app/models/concerns/has_wiki_page_meta_attributes.rb164
-rw-r--r--app/models/concerns/has_wiki_page_slug_attributes.rb25
-rw-r--r--app/models/concerns/ignorable_columns.rb12
-rw-r--r--app/models/concerns/issuable.rb9
-rw-r--r--app/models/concerns/mentionable.rb41
-rw-r--r--app/models/concerns/optimized_issuable_label_filter.rb37
-rw-r--r--app/models/concerns/project_features_compatibility.rb8
-rw-r--r--app/models/concerns/protected_ref.rb6
-rw-r--r--app/models/concerns/protected_ref_access.rb1
-rw-r--r--app/models/concerns/reactive_caching.rb3
-rw-r--r--app/models/concerns/repository_storage_movable.rb121
-rw-r--r--app/models/concerns/routable.rb47
-rw-r--r--app/models/concerns/shardable.rb1
-rw-r--r--app/models/concerns/timebox.rb6
-rw-r--r--app/models/concerns/token_authenticatable.rb7
-rw-r--r--app/models/concerns/token_authenticatable_strategies/base.rb10
-rw-r--r--app/models/concerns/triggerable_hooks.rb5
-rw-r--r--app/models/container_repository.rb3
-rw-r--r--app/models/custom_emoji.rb2
-rw-r--r--app/models/cycle_analytics/level_base.rb57
-rw-r--r--app/models/cycle_analytics/project_level.rb9
-rw-r--r--app/models/dependency_proxy.rb2
-rw-r--r--app/models/dependency_proxy/manifest.rb16
-rw-r--r--app/models/dependency_proxy/registry.rb9
-rw-r--r--app/models/deployment.rb24
-rw-r--r--app/models/diff_note.rb4
-rw-r--r--app/models/environment.rb41
-rw-r--r--app/models/experiment.rb17
-rw-r--r--app/models/experiment_subject.rb28
-rw-r--r--app/models/exported_protected_branch.rb5
-rw-r--r--app/models/group.rb22
-rw-r--r--app/models/group_import_state.rb4
-rw-r--r--app/models/identity.rb3
-rw-r--r--app/models/issue.rb8
-rw-r--r--app/models/iteration.rb6
-rw-r--r--app/models/label.rb2
-rw-r--r--app/models/label_priority.rb5
-rw-r--r--app/models/list.rb2
-rw-r--r--app/models/merge_request.rb68
-rw-r--r--app/models/merge_request/metrics.rb5
-rw-r--r--app/models/merge_request_diff.rb34
-rw-r--r--app/models/merge_request_reviewer.rb2
-rw-r--r--app/models/milestone.rb4
-rw-r--r--app/models/namespace.rb1
-rw-r--r--app/models/namespace_onboarding_action.rb27
-rw-r--r--app/models/note.rb5
-rw-r--r--app/models/notification_setting.rb2
-rw-r--r--app/models/packages/event.rb5
-rw-r--r--app/models/packages/package.rb26
-rw-r--r--app/models/packages/package_file.rb11
-rw-r--r--app/models/pages/lookup_path.rb33
-rw-r--r--app/models/pages_domain.rb8
-rw-r--r--app/models/personal_access_token.rb13
-rw-r--r--app/models/project.rb89
-rw-r--r--app/models/project_feature.rb4
-rw-r--r--app/models/project_repository.rb1
-rw-r--r--app/models/project_repository_storage_move.rb105
-rw-r--r--app/models/project_services/datadog_service.rb124
-rw-r--r--app/models/project_services/jenkins_service.rb91
-rw-r--r--app/models/project_services/jira_service.rb5
-rw-r--r--app/models/project_services/mock_deployment_service.rb6
-rw-r--r--app/models/project_services/pipelines_email_service.rb4
-rw-r--r--app/models/project_statistics.rb2
-rw-r--r--app/models/protected_branch.rb4
-rw-r--r--app/models/protected_branch/push_access_level.rb12
-rw-r--r--app/models/raw_usage_data.rb2
-rw-r--r--app/models/redirect_route.rb2
-rw-r--r--app/models/release.rb2
-rw-r--r--app/models/release_highlight.rb102
-rw-r--r--app/models/repository.rb5
-rw-r--r--app/models/resource_event.rb8
-rw-r--r--app/models/resource_label_event.rb13
-rw-r--r--app/models/resource_state_event.rb4
-rw-r--r--app/models/resource_timebox_event.rb10
-rw-r--r--app/models/route.rb2
-rw-r--r--app/models/sentry_issue.rb5
-rw-r--r--app/models/service.rb26
-rw-r--r--app/models/snippet.rb6
-rw-r--r--app/models/snippet_blob.rb4
-rw-r--r--app/models/snippet_repository_storage_move.rb24
-rw-r--r--app/models/suggestion.rb3
-rw-r--r--app/models/system_note_metadata.rb7
-rw-r--r--app/models/terraform/state.rb29
-rw-r--r--app/models/terraform/state_version.rb4
-rw-r--r--app/models/timelog.rb4
-rw-r--r--app/models/todo.rb6
-rw-r--r--app/models/user.rb59
-rw-r--r--app/models/user_callout.rb3
-rw-r--r--app/models/user_detail.rb2
-rw-r--r--app/models/wiki_page/meta.rb135
-rw-r--r--app/models/wiki_page/slug.rb23
-rw-r--r--app/models/zoom_meeting.rb10
-rw-r--r--app/policies/base_policy.rb4
-rw-r--r--app/policies/ci/build_policy.rb15
-rw-r--r--app/policies/concerns/policy_actor.rb4
-rw-r--r--app/policies/global_policy.rb3
-rw-r--r--app/policies/group_policy.rb5
-rw-r--r--app/policies/issuable_policy.rb2
-rw-r--r--app/policies/namespace_policy.rb1
-rw-r--r--app/policies/project_ci_cd_setting_policy.rb5
-rw-r--r--app/policies/project_policy.rb27
-rw-r--r--app/policies/timebox_policy.rb10
-rw-r--r--app/policies/user_policy.rb5
-rw-r--r--app/presenters/alert_management/alert_presenter.rb3
-rw-r--r--app/presenters/analytics/cycle_analytics/stage_presenter.rb58
-rw-r--r--app/presenters/ci/pipeline_presenter.rb3
-rw-r--r--app/presenters/gitlab/whats_new/item_presenter.rb22
-rw-r--r--app/presenters/packages/composer/packages_presenter.rb2
-rw-r--r--app/presenters/project_presenter.rb23
-rw-r--r--app/presenters/projects/import_export/project_export_presenter.rb4
-rw-r--r--app/presenters/search_service_presenter.rb43
-rw-r--r--app/serializers/admin/user_entity.rb31
-rw-r--r--app/serializers/admin/user_serializer.rb7
-rw-r--r--app/serializers/codequality_degradation_entity.rb14
-rw-r--r--app/serializers/codequality_reports_comparer_entity.rb15
-rw-r--r--app/serializers/codequality_reports_comparer_serializer.rb5
-rw-r--r--app/serializers/concerns/user_status_tooltip.rb14
-rw-r--r--app/serializers/diff_file_base_entity.rb2
-rw-r--r--app/serializers/diffs_metadata_entity.rb4
-rw-r--r--app/serializers/environment_entity.rb8
-rw-r--r--app/serializers/import/bulk_import_entity.rb4
-rw-r--r--app/serializers/merge_request_assignee_entity.rb9
-rw-r--r--app/serializers/merge_request_current_user_entity.rb24
-rw-r--r--app/serializers/merge_request_reviewer_entity.rb9
-rw-r--r--app/serializers/merge_request_sidebar_extras_entity.rb4
-rw-r--r--app/serializers/merge_request_user_entity.rb27
-rw-r--r--app/serializers/merge_request_widget_entity.rb17
-rw-r--r--app/serializers/paginated_diff_entity.rb2
-rw-r--r--app/serializers/pipeline_serializer.rb2
-rw-r--r--app/serializers/rollout_status_entity.rb18
-rw-r--r--app/serializers/rollout_statuses/ingress_entity.rb7
-rw-r--r--app/serializers/user_entity.rb2
-rw-r--r--app/serializers/user_serializer.rb4
-rw-r--r--app/services/admin/propagate_integration_service.rb2
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb12
-rw-r--r--app/services/application_settings/update_service.rb26
-rw-r--r--app/services/auth/container_registry_authentication_service.rb12
-rw-r--r--app/services/auth/dependency_proxy_authentication_service.rb43
-rw-r--r--app/services/boards/lists/create_service.rb28
-rw-r--r--app/services/boards/lists/generate_service.rb6
-rw-r--r--app/services/ci/compare_codequality_reports_service.rb17
-rw-r--r--app/services/ci/create_pipeline_service.rb5
-rw-r--r--app/services/ci/list_config_variables_service.rb24
-rw-r--r--app/services/ci/test_cases_service.rb44
-rw-r--r--app/services/ci/test_failure_history_service.rb95
-rw-r--r--app/services/ci/update_build_state_service.rb20
-rw-r--r--app/services/clusters/applications/prometheus_health_check_service.rb6
-rw-r--r--app/services/clusters/aws/authorize_role_service.rb24
-rw-r--r--app/services/clusters/aws/fetch_credentials_service.rb9
-rw-r--r--app/services/concerns/exclusive_lease_guard.rb2
-rw-r--r--app/services/concerns/users/participable_service.rb59
-rw-r--r--app/services/container_expiration_policies/cleanup_service.rb3
-rw-r--r--app/services/dependency_proxy/auth_token_service.rb21
-rw-r--r--app/services/dependency_proxy/base_service.rb10
-rw-r--r--app/services/dependency_proxy/download_blob_service.rb10
-rw-r--r--app/services/dependency_proxy/find_or_create_manifest_service.rb52
-rw-r--r--app/services/dependency_proxy/head_manifest_service.rb29
-rw-r--r--app/services/dependency_proxy/pull_manifest_service.rb18
-rw-r--r--app/services/environments/canary_ingress/update_service.rb70
-rw-r--r--app/services/feature_flags/create_service.rb9
-rw-r--r--app/services/git/base_hooks_service.rb5
-rw-r--r--app/services/git/branch_hooks_service.rb2
-rw-r--r--app/services/groups/create_service.rb2
-rw-r--r--app/services/groups/transfer_service.rb13
-rw-r--r--app/services/import/bitbucket_server_service.rb8
-rw-r--r--app/services/import/github_service.rb13
-rw-r--r--app/services/incident_management/incidents/update_severity_service.rb2
-rw-r--r--app/services/integrations/test/project_service.rb6
-rw-r--r--app/services/issuable/import_csv/base_service.rb15
-rw-r--r--app/services/issues/base_service.rb16
-rw-r--r--app/services/issues/clone_service.rb90
-rw-r--r--app/services/issues/create_service.rb16
-rw-r--r--app/services/issues/export_csv_service.rb2
-rw-r--r--app/services/issues/update_service.rb15
-rw-r--r--app/services/jira/requests/base.rb7
-rw-r--r--app/services/jira_connect/sync_service.rb12
-rw-r--r--app/services/members/create_service.rb6
-rw-r--r--app/services/members/invitation_reminder_email_service.rb6
-rw-r--r--app/services/merge_requests/after_create_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb2
-rw-r--r--app/services/merge_requests/update_service.rb2
-rw-r--r--app/services/notes/create_service.rb2
-rw-r--r--app/services/notification_service.rb16
-rw-r--r--app/services/onboarding_progress_service.rb11
-rw-r--r--app/services/packages/composer/create_package_service.rb2
-rw-r--r--app/services/packages/conan/create_package_file_service.rb9
-rw-r--r--app/services/packages/create_event_service.rb10
-rw-r--r--app/services/packages/create_package_service.rb16
-rw-r--r--app/services/packages/generic/create_package_file_service.rb5
-rw-r--r--app/services/packages/generic/find_or_create_package_service.rb6
-rw-r--r--app/services/packages/maven/find_or_create_package_service.rb2
-rw-r--r--app/services/packages/npm/create_package_service.rb8
-rw-r--r--app/services/packages/pypi/create_package_service.rb3
-rw-r--r--app/services/pages/legacy_storage_lease.rb28
-rw-r--r--app/services/pages/zip_directory_service.rb83
-rw-r--r--app/services/post_receive_service.rb6
-rw-r--r--app/services/projects/alerting/notify_service.rb14
-rw-r--r--app/services/projects/container_repository/delete_tags_service.rb1
-rw-r--r--app/services/projects/participants_service.rb2
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb40
-rw-r--r--app/services/projects/schedule_bulk_repository_shard_moves_service.rb35
-rw-r--r--app/services/projects/transfer_service.rb11
-rw-r--r--app/services/projects/update_pages_service.rb16
-rw-r--r--app/services/releases/base_service.rb20
-rw-r--r--app/services/releases/create_service.rb22
-rw-r--r--app/services/resource_events/change_labels_service.rb2
-rw-r--r--app/services/service_desk_settings/update_service.rb2
-rw-r--r--app/services/submit_usage_ping_service.rb2
-rw-r--r--app/services/system_hooks_service.rb35
-rw-r--r--app/services/system_note_service.rb4
-rw-r--r--app/services/system_notes/issuables_service.rb23
-rw-r--r--app/services/upload_service.rb8
-rw-r--r--app/services/users/approve_service.rb9
-rw-r--r--app/services/users/reject_service.rb28
-rw-r--r--app/services/users/set_status_service.rb19
-rw-r--r--app/services/users/validate_otp_service.rb6
-rw-r--r--app/uploaders/terraform/state_uploader.rb22
-rw-r--r--app/uploaders/terraform/versioned_state_uploader.rb23
-rw-r--r--app/validators/json_schema_validator.rb11
-rw-r--r--app/validators/json_schemas/codeclimate.json34
-rw-r--r--app/validators/json_schemas/http_integration_payload_attribute_mapping.json14
-rw-r--r--app/validators/json_schemas/vulnerability_finding_details.json182
-rw-r--r--app/views/admin/appearances/_form.html.haml6
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml3
-rw-r--r--app/views/admin/application_settings/_eks.html.haml5
-rw-r--r--app/views/admin/application_settings/_ip_limits.html.haml26
-rw-r--r--app/views/admin/application_settings/_kroki.html.haml25
-rw-r--r--app/views/admin/application_settings/_plantuml.html.haml2
-rw-r--r--app/views/admin/application_settings/_signup.html.haml4
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml8
-rw-r--r--app/views/admin/application_settings/general.html.haml16
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml8
-rw-r--r--app/views/admin/dashboard/index.html.haml2
-rw-r--r--app/views/admin/dev_ops_report/show.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml1
-rw-r--r--app/views/admin/hooks/_form.html.haml8
-rw-r--r--app/views/admin/labels/index.html.haml2
-rw-r--r--app/views/admin/runners/_sort_dropdown.html.haml2
-rw-r--r--app/views/admin/runners/show.html.haml17
-rw-r--r--app/views/admin/users/_approve_user.html.haml2
-rw-r--r--app/views/admin/users/_modals.html.haml9
-rw-r--r--app/views/admin/users/_reject_pending_user.html.haml7
-rw-r--r--app/views/admin/users/_user.html.haml15
-rw-r--r--app/views/admin/users/_user_deactivation_effects.html.haml18
-rw-r--r--app/views/admin/users/_user_reject_effects.html.haml10
-rw-r--r--app/views/admin/users/index.html.haml8
-rw-r--r--app/views/admin/users/show.html.haml104
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml6
-rw-r--r--app/views/clusters/clusters/show.html.haml1
-rw-r--r--app/views/dashboard/todos/index.html.haml7
-rw-r--r--app/views/devise/confirmations/almost_there.haml6
-rw-r--r--app/views/devise/shared/_signup_box.html.haml9
-rw-r--r--app/views/devise/shared/_signup_omniauth_provider_list.haml9
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers.haml12
-rw-r--r--app/views/devise/shared/_signup_omniauth_providers_top.haml3
-rw-r--r--app/views/devise/unlocks/new.html.haml2
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml2
-rw-r--r--app/views/explore/projects/_filter.html.haml2
-rw-r--r--app/views/groups/_create_chat_team.html.haml6
-rw-r--r--app/views/groups/_home_panel.html.haml6
-rw-r--r--app/views/groups/_import_group_from_another_instance_panel.html.haml25
-rw-r--r--app/views/groups/_import_group_from_file_panel.html.haml (renamed from app/views/groups/_import_group_pane.html.haml)46
-rw-r--r--app/views/groups/_new_group_fields.html.haml16
-rw-r--r--app/views/groups/_subgroups_and_projects.html.haml2
-rw-r--r--app/views/groups/dependency_proxies/_url.html.haml2
-rw-r--r--app/views/groups/dependency_proxies/show.html.haml2
-rw-r--r--app/views/groups/edit.html.haml9
-rw-r--r--app/views/groups/group_members/index.html.haml53
-rw-r--r--app/views/groups/new.html.haml9
-rw-r--r--app/views/groups/registry/repositories/index.html.haml2
-rw-r--r--app/views/groups/show.html.haml3
-rw-r--r--app/views/import/_githubish_status.html.haml1
-rw-r--r--app/views/import/bulk_imports/status.html.haml13
-rw-r--r--app/views/import/github/status.html.haml4
-rw-r--r--app/views/import/google_code/new.html.haml63
-rw-r--r--app/views/import/google_code/new_user_map.html.haml37
-rw-r--r--app/views/import/google_code/status.html.haml78
-rw-r--r--app/views/import/manifest/_form.html.haml4
-rw-r--r--app/views/jira_connect/subscriptions/index.html.haml3
-rw-r--r--app/views/layouts/_google_analytics.html.haml2
-rw-r--r--app/views/layouts/_google_tag_manager_head.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml2
-rw-r--r--app/views/layouts/_img_loader.html.haml2
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml2
-rw-r--r--app/views/layouts/_init_client_detection_flags.html.haml2
-rw-r--r--app/views/layouts/_loading_hints.html.haml1
-rw-r--r--app/views/layouts/_matomo.html.haml15
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/_piwik.html.haml15
-rw-r--r--app/views/layouts/_snowplow.html.haml2
-rw-r--r--app/views/layouts/_startup_css_activation.haml2
-rw-r--r--app/views/layouts/_startup_js.html.haml2
-rw-r--r--app/views/layouts/errors.html.haml2
-rw-r--r--app/views/layouts/group.html.haml2
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml3
-rw-r--r--app/views/layouts/jira_connect.html.haml4
-rw-r--r--app/views/layouts/nav/_breadcrumbs.html.haml9
-rw-r--r--app/views/layouts/nav/groups_dropdown/_show.html.haml4
-rw-r--r--app/views/layouts/nav/projects_dropdown/_show.html.haml6
-rw-r--r--app/views/layouts/nav/sidebar/_analytics_links.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml9
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml7
-rw-r--r--app/views/layouts/project.html.haml2
-rw-r--r--app/views/layouts/snippets.html.haml2
-rw-r--r--app/views/notify/issue_cloned_email.html.haml7
-rw-r--r--app/views/notify/issue_cloned_email.text.erb8
-rw-r--r--app/views/notify/new_release_email.html.haml2
-rw-r--r--app/views/notify/user_admin_rejection_email.html.haml5
-rw-r--r--app/views/notify/user_admin_rejection_email.text.erb6
-rw-r--r--app/views/profiles/accounts/show.html.haml13
-rw-r--r--app/views/profiles/keys/_form.html.haml2
-rw-r--r--app/views/profiles/notifications/_group_settings.html.haml2
-rw-r--r--app/views/profiles/notifications/show.html.haml12
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml33
-rw-r--r--app/views/profiles/preferences/show.html.haml13
-rw-r--r--app/views/profiles/two_factor_auths/_codes.html.haml27
-rw-r--r--app/views/profiles/two_factor_auths/codes.html.haml4
-rw-r--r--app/views/profiles/two_factor_auths/create.html.haml8
-rw-r--r--app/views/projects/_archived_notice.html.haml2
-rw-r--r--app/views/projects/_commit_button.html.haml4
-rw-r--r--app/views/projects/_customize_workflow.html.haml2
-rw-r--r--app/views/projects/_files.html.haml7
-rw-r--r--app/views/projects/_fork_suggestion.html.haml4
-rw-r--r--app/views/projects/_home_panel.html.haml4
-rw-r--r--app/views/projects/_import_project_pane.html.haml56
-rw-r--r--app/views/projects/_invite_members.html.haml8
-rw-r--r--app/views/projects/_project_templates.html.haml10
-rw-r--r--app/views/projects/_service_desk_settings.html.haml1
-rw-r--r--app/views/projects/blob/_content.html.haml4
-rw-r--r--app/views/projects/blob/_viewer_switcher.html.haml2
-rw-r--r--app/views/projects/blob/edit.html.haml5
-rw-r--r--app/views/projects/blob/new.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml2
-rw-r--r--app/views/projects/buttons/_clone.html.haml6
-rw-r--r--app/views/projects/ci/builds/_build.html.haml2
-rw-r--r--app/views/projects/ci/pipeline_editor/show.html.haml2
-rw-r--r--app/views/projects/commit/_commit_box.html.haml2
-rw-r--r--app/views/projects/commit/_verified_signature_badge.html.haml2
-rw-r--r--app/views/projects/commit/x509/_unverified_signature_badge.html.haml2
-rw-r--r--app/views/projects/commits/_commits.html.haml8
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/compare/_form.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml9
-rw-r--r--app/views/projects/deployments/_actions.haml2
-rw-r--r--app/views/projects/deployments/_commit.html.haml2
-rw-r--r--app/views/projects/diffs/_file_header.html.haml4
-rw-r--r--app/views/projects/diffs/_replaced_image_diff.html.haml10
-rw-r--r--app/views/projects/diffs/_stats.html.haml2
-rw-r--r--app/views/projects/edit.html.haml2
-rw-r--r--app/views/projects/empty.html.haml5
-rw-r--r--app/views/projects/forks/index.html.haml2
-rw-r--r--app/views/projects/graphs/charts.html.haml2
-rw-r--r--app/views/projects/graphs/show.html.haml2
-rw-r--r--app/views/projects/issuable/_show.html.haml1
-rw-r--r--app/views/projects/issues/_discussion.html.haml5
-rw-r--r--app/views/projects/issues/_issue.html.haml111
-rw-r--r--app/views/projects/issues/_new_branch.html.haml4
-rw-r--r--app/views/projects/jobs/_table.html.haml16
-rw-r--r--app/views/projects/jobs/index.html.haml4
-rw-r--r--app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml37
-rw-r--r--app/views/projects/merge_requests/_how_to_merge.html.haml56
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml4
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml23
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/_commit_stats.html.haml15
-rw-r--r--app/views/projects/merge_requests/conflicts/_file_actions.html.haml16
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml7
-rw-r--r--app/views/projects/merge_requests/show.html.haml5
-rw-r--r--app/views/projects/merge_requests/widget/open/_error.html.haml2
-rw-r--r--app/views/projects/network/show.html.haml2
-rw-r--r--app/views/projects/new.html.haml5
-rw-r--r--app/views/projects/no_repo.html.haml4
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/pipelines/charts.html.haml13
-rw-r--r--app/views/projects/pipelines/index.html.haml2
-rw-r--r--app/views/projects/pipelines/new.html.haml4
-rw-r--r--app/views/projects/pipelines/show.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml3
-rw-r--r--app/views/projects/registry/settings/_index.haml3
-rw-r--r--app/views/projects/runners/_shared_runners.html.haml28
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml2
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/projects/settings/operations/show.html.haml2
-rw-r--r--app/views/projects/show.html.haml2
-rw-r--r--app/views/projects/tags/_tag.html.haml3
-rw-r--r--app/views/projects/tags/index.html.haml2
-rw-r--r--app/views/projects/tags/new.html.haml2
-rw-r--r--app/views/projects/terraform/index.html.haml4
-rw-r--r--app/views/projects/tree/_truncated_notice_tree_row.html.haml2
-rw-r--r--app/views/registrations/experience_levels/show.html.haml8
-rw-r--r--app/views/registrations/welcome/show.html.haml14
-rw-r--r--app/views/search/_filter.html.haml18
-rw-r--r--app/views/search/_form.html.haml8
-rw-r--r--app/views/search/_results.html.haml31
-rw-r--r--app/views/search/_results_status.html.haml25
-rw-r--r--app/views/search/_sort_dropdown.html.haml4
-rw-r--r--app/views/shared/_alert_info.html.haml6
-rw-r--r--app/views/shared/_choose_avatar_button.html.haml2
-rw-r--r--app/views/shared/_clone_panel.html.haml16
-rw-r--r--app/views/shared/_file_picker_button.html.haml4
-rw-r--r--app/views/shared/_group_form.html.haml8
-rw-r--r--app/views/shared/_group_form_description.html.haml5
-rw-r--r--app/views/shared/_issues.html.haml5
-rw-r--r--app/views/shared/_md_preview.html.haml2
-rw-r--r--app/views/shared/_merge_requests.html.haml5
-rw-r--r--app/views/shared/_milestones_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/_no_password.html.haml2
-rw-r--r--app/views/shared/_no_ssh.html.haml2
-rw-r--r--app/views/shared/_service_settings.html.haml4
-rw-r--r--app/views/shared/_web_ide_button.html.haml4
-rw-r--r--app/views/shared/access_tokens/_table.html.haml2
-rw-r--r--app/views/shared/boards/_show.html.haml24
-rw-r--r--app/views/shared/deploy_tokens/_table.html.haml2
-rw-r--r--app/views/shared/groups/_dropdown.html.haml13
-rw-r--r--app/views/shared/groups/_visibility_level.html.haml3
-rw-r--r--app/views/shared/icons/_icon_mattermost.svg2
-rw-r--r--app/views/shared/integrations/_index.html.haml7
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml2
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml26
-rw-r--r--app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml37
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml47
-rw-r--r--app/views/shared/issuable/_form.html.haml5
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml18
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml4
-rw-r--r--app/views/shared/issuable/form/_branch_chooser.html.haml2
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml2
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml3
-rw-r--r--app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml2
-rw-r--r--app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml8
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml2
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml40
-rw-r--r--app/views/shared/labels/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/members/_group.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/milestones/_header.html.haml2
-rw-r--r--app/views/shared/milestones/_milestone.html.haml2
-rw-r--r--app/views/shared/notes/_comment_button.html.haml6
-rw-r--r--app/views/shared/notes/_edit_form.html.haml4
-rw-r--r--app/views/shared/notes/_form.html.haml2
-rw-r--r--app/views/shared/notifications/_button.html.haml6
-rw-r--r--app/views/shared/projects/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/projects/protected_branches/_update_protected_branch.html.haml4
-rw-r--r--app/views/shared/web_hooks/_form.html.haml58
-rw-r--r--app/views/shared/web_hooks/_test_button.html.haml2
-rw-r--r--app/views/shared/wikis/_form.html.haml2
-rw-r--r--app/views/shared/wikis/_sidebar.html.haml14
-rw-r--r--app/views/shared/wikis/git_access.html.haml (renamed from app/views/projects/wikis/git_access.html.haml)2
-rw-r--r--app/views/shared/wikis/git_error.html.haml14
-rw-r--r--app/views/users/_overview.html.haml2
-rw-r--r--app/views/users/show.html.haml8
-rw-r--r--app/workers/all_queues.yml146
-rw-r--r--app/workers/analytics/instance_statistics/count_job_trigger_worker.rb2
-rw-r--r--app/workers/analytics/instance_statistics/counter_job_worker.rb2
-rw-r--r--app/workers/approve_blocked_pending_approval_users_worker.rb17
-rw-r--r--app/workers/build_finished_worker.rb5
-rw-r--r--app/workers/ci/test_failure_history_worker.rb16
-rw-r--r--app/workers/clusters/applications/check_prometheus_health_worker.rb2
-rw-r--r--app/workers/concerns/gitlab/github_import/object_importer.rb39
-rw-r--r--app/workers/concerns/gitlab/github_import/rescheduling_methods.rb2
-rw-r--r--app/workers/concerns/gitlab/github_import/stage_methods.rb39
-rw-r--r--app/workers/concerns/limited_capacity/worker.rb2
-rw-r--r--app/workers/concerns/reenqueuer.rb7
-rw-r--r--app/workers/concerns/worker_context.rb4
-rw-r--r--app/workers/create_evidence_worker.rb20
-rw-r--r--app/workers/create_note_diff_file_worker.rb2
-rw-r--r--app/workers/delete_diff_files_worker.rb2
-rw-r--r--app/workers/environments/canary_ingress/update_worker.rb22
-rw-r--r--app/workers/gitlab/github_import/advance_stage_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_review_worker.rb25
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/stage/finish_import_worker.rb9
-rw-r--r--app/workers/gitlab/github_import/stage/import_base_data_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/stage/import_notes_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb29
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb36
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb3
-rw-r--r--app/workers/gitlab/github_import/stage/import_repository_worker.rb1
-rw-r--r--app/workers/gitlab_performance_bar_stats_worker.rb34
-rw-r--r--app/workers/gitlab_usage_ping_worker.rb4
-rw-r--r--app/workers/import_issues_csv_worker.rb13
-rw-r--r--app/workers/jira_connect/sync_branch_worker.rb1
-rw-r--r--app/workers/jira_connect/sync_builds_worker.rb24
-rw-r--r--app/workers/jira_connect/sync_merge_request_worker.rb2
-rw-r--r--app/workers/member_invitation_reminder_emails_worker.rb2
-rw-r--r--app/workers/merge_request_cleanup_refs_worker.rb2
-rw-r--r--app/workers/merge_request_mergeability_check_worker.rb2
-rw-r--r--app/workers/migrate_external_diffs_worker.rb2
-rw-r--r--app/workers/namespaces/onboarding_user_added_worker.rb17
-rw-r--r--app/workers/new_merge_request_worker.rb2
-rw-r--r--app/workers/project_cache_worker.rb3
-rw-r--r--app/workers/project_schedule_bulk_repository_shard_moves_worker.rb13
-rw-r--r--app/workers/purge_dependency_proxy_cache_worker.rb1
-rw-r--r--app/workers/releases/create_evidence_worker.rb23
-rw-r--r--app/workers/releases/manage_evidence_worker.rb24
-rw-r--r--app/workers/repository_import_worker.rb2
-rw-r--r--app/workers/repository_update_remote_mirror_worker.rb3
-rw-r--r--app/workers/schedule_merge_request_cleanup_refs_worker.rb2
-rw-r--r--app/workers/schedule_migrate_external_diffs_worker.rb2
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb2
-rw-r--r--app/workers/trending_projects_worker.rb4
-rw-r--r--app/workers/update_merge_requests_worker.rb2
1514 files changed, 20910 insertions, 11841 deletions
diff --git a/app/assets/images/checkmark.png b/app/assets/images/checkmark.png
new file mode 100644
index 00000000000..6e47fda5cdc
--- /dev/null
+++ b/app/assets/images/checkmark.png
Binary files differ
diff --git a/app/assets/images/chevron-down.png b/app/assets/images/chevron-down.png
new file mode 100644
index 00000000000..3f269e05d0b
--- /dev/null
+++ b/app/assets/images/chevron-down.png
Binary files differ
diff --git a/app/assets/images/jobs-empty-state.svg b/app/assets/images/jobs-empty-state.svg
new file mode 100644
index 00000000000..e6e0681a002
--- /dev/null
+++ b/app/assets/images/jobs-empty-state.svg
@@ -0,0 +1,33 @@
+<svg width="234" height="162" viewBox="0 0 234 162" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M174.68 56.344H200.5C215.412 56.344 227.5 44.1787 227.5 29.172C227.5 14.1653 215.412 2 200.5 2C185.588 2 173.5 14.1653 173.5 29.172C173.5 36.2548 176.193 42.7046 180.604 47.5412" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/>
+<path d="M145.5 76.4714C145.5 65.3553 154.454 56.344 165.5 56.344" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/>
+<path d="M102.5 121.758H29.5C14.5883 121.758 2.5 109.593 2.5 94.586C2.5 79.5794 14.5883 67.4141 29.5 67.4141C44.4117 67.4141 56.5 79.5794 56.5 94.586C56.5 101.669 53.8072 108.119 49.3957 112.955" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/>
+<path d="M67.0466 121.758H52.5C42.5589 121.758 34.5 129.868 34.5 139.873C34.5 149.877 42.5589 157.987 52.5 157.987C62.4411 157.987 70.5 149.877 70.5 139.873C70.5 137.478 70.0384 135.192 69.1998 133.1" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/>
+<g clip-path="url(#clip0)">
+<path d="M55.0188 135.3C55.1617 134.764 54.8451 134.211 54.3117 134.068C53.7782 133.925 53.2298 134.243 53.0869 134.78L49.9811 146.445C49.8381 146.981 50.1547 147.534 50.6882 147.677C51.2217 147.821 51.77 147.503 51.9129 146.965L55.0188 135.3Z" fill="#FC6D26"/>
+<path d="M49.2071 137.142C49.5976 137.534 49.5976 138.172 49.2071 138.565L46.9142 140.873L49.2071 143.18C49.5976 143.573 49.5976 144.211 49.2071 144.603C48.8166 144.997 48.1834 144.997 47.7929 144.603L44.7929 141.584C44.4024 141.192 44.4024 140.554 44.7929 140.161L47.7929 137.142C48.1834 136.748 48.8166 136.748 49.2071 137.142Z" fill="#FC6D26"/>
+<path d="M55.7929 137.142C55.4024 137.534 55.4024 138.172 55.7929 138.565L58.0858 140.873L55.7929 143.18C55.4024 143.573 55.4024 144.211 55.7929 144.603C56.1834 144.997 56.8166 144.997 57.2071 144.603L60.2071 141.584C60.5976 141.192 60.5976 140.554 60.2071 140.161L57.2071 137.142C56.8166 136.748 56.1834 136.748 55.7929 137.142Z" fill="#FC6D26"/>
+</g>
+<path d="M212.102 160C222.815 160 231.5 151.214 231.5 140.376C231.5 129.537 222.815 120.752 212.102 120.752H151.5" stroke="#C2B7E6" stroke-width="4" stroke-linecap="round"/>
+<path d="M126.5 138.866C107.171 138.866 91.5 123.096 91.5 103.643C91.5 84.191 107.171 68.4204 126.5 68.4204C145.829 68.4204 161.5 84.191 161.5 103.643C161.5 123.096 145.829 138.866 126.5 138.866ZM126.5 131.451C141.76 131.451 154.132 119.001 154.132 103.643C154.132 88.2861 141.76 75.8358 126.5 75.8358C111.24 75.8358 98.8684 88.2861 98.8684 103.643C98.8684 119.001 111.24 131.451 126.5 131.451Z" fill="#FC6D26"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M126.126 87.1326C135.355 87.1326 142.906 94.5624 142.906 103.643C142.906 112.724 135.355 120.154 126.126 120.154C120.672 120.154 115.638 117.265 112.281 113.137L126.126 103.643V87.1326Z" fill="#6E49CB"/>
+<g clip-path="url(#clip1)">
+<path d="M29.5 90.2659L24.3571 91.9534V93.1629C24.3571 94.9623 25.087 96.6872 26.3846 97.9546L29.5 100.997V90.2659Z" fill="#FC6D26"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5 86.8909L29.5 83.5159L41.5 86.8909V93.1115C41.5 96.6919 40.0551 100.126 37.4832 102.657L29.5 110.516L21.5168 102.657C18.9449 100.126 17.5 96.6919 17.5 93.1115V86.8909ZM20.9286 93.1115V89.4366L29.5 87.0259L38.0714 89.4366V93.1115C38.0714 95.7968 36.9878 98.3721 35.0588 100.271L29.5 105.743L23.9412 100.271C22.0122 98.3721 20.9286 95.7968 20.9286 93.1115Z" fill="#FC6D26"/>
+</g>
+<g clip-path="url(#clip2)">
+<path d="M210.857 19.7297L209.51 24.8237C208.922 27.0445 207.518 28.9576 205.581 30.1752L194.728 36.999L191.862 34.1146L198.642 23.1922C199.852 21.2431 201.753 19.8298 203.96 19.2386L209.022 17.8826C209.822 17.6681 210.644 18.1474 210.857 18.953C210.925 19.2075 210.925 19.4752 210.857 19.7297ZM207.292 21.4702L204.732 22.1561C203.261 22.5503 201.993 23.4925 201.187 24.7918L196.517 32.3146L203.992 27.6148C205.283 26.803 206.219 25.5276 206.611 24.0471L207.292 21.4702ZM196.5 38.2294L204 33.7007V35.2103C204 38.5451 201.314 41.2485 198 41.2485H196.5V38.2294ZM190.5 32.1912H187.5V30.6816C187.5 27.3468 190.186 24.6434 193.5 24.6434H195L190.5 32.1912Z" fill="#FC6D26"/>
+</g>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M209.914 132.822C209.384 132.822 208.875 133.032 208.5 133.407L204.796 137.111C204.613 137.293 204.5 137.544 204.5 137.822V144.822C204.5 145.926 205.395 146.822 206.5 146.822H216.5C217.605 146.822 218.5 145.926 218.5 144.822V137.822C218.5 137.546 218.388 137.296 218.207 137.115L214.5 133.407C214.125 133.032 213.616 132.822 213.086 132.822H209.914ZM215.086 136.822L213.086 134.822H212.5V136.822H215.086ZM210.5 134.822H209.914L207.914 136.822H210.5V134.822ZM206.5 138.822H216.5V144.822H206.5V138.822Z" fill="#FC6D26"/>
+<defs>
+<clipPath id="clip0">
+<rect width="16" height="13.6779" fill="white" transform="translate(44.5 134.033)"/>
+</clipPath>
+<clipPath id="clip1">
+<rect width="24" height="27.172" fill="white" transform="translate(17.5 83.5159)"/>
+</clipPath>
+<clipPath id="clip2">
+<rect width="24" height="24.1529" fill="white" transform="translate(187.5 17.0956)"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/app/assets/javascripts/admin/users/components/app.vue b/app/assets/javascripts/admin/users/components/app.vue
new file mode 100644
index 00000000000..a3abd904a6b
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/app.vue
@@ -0,0 +1,26 @@
+<script>
+import UsersTable from './users_table.vue';
+
+export default {
+ components: {
+ UsersTable,
+ },
+ props: {
+ users: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ paths: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <users-table :users="users" :paths="paths" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/admin/users/components/users_table.vue
new file mode 100644
index 00000000000..a2d68972519
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/users_table.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlTable } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const DEFAULT_TH_CLASSES =
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!';
+const thWidthClass = width => `gl-w-${width}p ${DEFAULT_TH_CLASSES}`;
+
+export default {
+ components: {
+ GlTable,
+ },
+ props: {
+ users: {
+ type: Array,
+ required: true,
+ },
+ paths: {
+ type: Object,
+ required: true,
+ },
+ },
+ fields: [
+ {
+ key: 'name',
+ label: __('Name'),
+ thClass: thWidthClass(40),
+ },
+ {
+ key: 'projectsCount',
+ label: __('Projects'),
+ thClass: thWidthClass(10),
+ },
+ {
+ key: 'createdAt',
+ label: __('Created on'),
+ thClass: thWidthClass(15),
+ },
+ {
+ key: 'lastActivityOn',
+ label: __('Last activity'),
+ thClass: thWidthClass(15),
+ },
+ {
+ key: 'settings',
+ label: '',
+ thClass: thWidthClass(20),
+ },
+ ],
+};
+</script>
+
+<template>
+ <div>
+ <gl-table
+ :items="users"
+ :fields="$options.fields"
+ :empty-text="s__('AdminUsers|No users found')"
+ show-empty
+ stacked="md"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js
new file mode 100644
index 00000000000..21780ee9984
--- /dev/null
+++ b/app/assets/javascripts/admin/users/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import AdminUsersApp from './components/app.vue';
+
+export default function(el = document.querySelector('#js-admin-users-app')) {
+ if (!el) {
+ return false;
+ }
+
+ const { users, paths } = el.dataset;
+
+ return new Vue({
+ el,
+ render: createElement =>
+ createElement(AdminUsersApp, {
+ props: {
+ users: convertObjectPropsToCamelCase(JSON.parse(users), { deep: true }),
+ paths: convertObjectPropsToCamelCase(JSON.parse(paths)),
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
index df07038151e..c39a72a45b9 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue
@@ -27,25 +27,12 @@ export default {
<gl-dropdown-item
:key="user.username"
data-testid="assigneeDropdownItem"
- class="assignee-dropdown-item gl-vertical-align-middle"
:active="active"
active-class="is-active"
+ :avatar-url="user.avatar_url"
+ :secondary-text="`@${user.username}`"
@click="$emit('update-alert-assignees', user.username)"
>
- <span class="gl-relative mr-2">
- <img
- :alt="user.username"
- :src="user.avatar_url"
- :width="32"
- class="avatar avatar-inline gl-m-0 s32"
- data-qa-selector="avatar_image"
- />
- </span>
- <span class="d-flex gl-flex-direction-column gl-overflow-hidden">
- <strong class="dropdown-menu-user-full-name">
- {{ user.name }}
- </strong>
- <span class="dropdown-menu-user-username"> {{ user.username }}</span>
- </span>
+ {{ user.name }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
index 5e4fd56738b..3af68d42ddf 100644
--- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
+++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue
@@ -13,7 +13,7 @@ import {
} from '@gitlab/ui';
import { debounce } from 'lodash';
import axios from '~/lib/utils/axios_utils';
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql';
import SidebarAssignee from './sidebar_assignee.vue';
@@ -96,7 +96,10 @@ export default {
.sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); // eslint-disable-line no-nested-ternary
},
dropdownClass() {
- return this.isDropdownShowing ? 'show' : 'gl-display-none';
+ return this.isDropdownShowing ? 'dropdown-menu-selectable show' : 'gl-display-none';
+ },
+ dropDownTitle() {
+ return this.userName ?? __('Select assignee');
},
userListValid() {
return !this.isDropdownSearching && this.users.length > 0;
@@ -217,81 +220,80 @@ export default {
</a>
</p>
- <div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
- <gl-dropdown
- ref="dropdown"
- :text="userName"
- class="w-100"
- toggle-class="dropdown-menu-toggle"
- @keydown.esc.native="hideDropdown"
- @hide="hideDropdown"
- >
- <p class="gl-new-dropdown-header-top">
- {{ __('Assign To') }}
- </p>
- <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" />
- <div class="dropdown-content dropdown-body">
- <template v-if="userListValid">
- <gl-dropdown-item
- :active="!userName"
- active-class="is-active"
- @click="updateAlertAssignees('')"
- >
- {{ __('Unassigned') }}
- </gl-dropdown-item>
- <gl-dropdown-divider />
-
- <gl-dropdown-section-header>
- {{ __('Assignee') }}
- </gl-dropdown-section-header>
- <sidebar-assignee
- v-for="user in sortedUsers"
- :key="user.username"
- :user="user"
- :active="user.active"
- @update-alert-assignees="updateAlertAssignees"
- />
- </template>
- <p v-else-if="userListEmpty" class="mx-3 my-2">
- {{ __('No Matching Results') }}
- </p>
- <gl-loading-icon v-else />
- </div>
- </gl-dropdown>
- </div>
+ <gl-dropdown
+ ref="dropdown"
+ :text="dropDownTitle"
+ class="gl-w-full"
+ :class="dropdownClass"
+ toggle-class="dropdown-menu-toggle"
+ @keydown.esc.native="hideDropdown"
+ @hide="hideDropdown"
+ >
+ <p class="gl-new-dropdown-header-top">
+ {{ __('Assign To') }}
+ </p>
+ <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" />
+ <div class="dropdown-content dropdown-body">
+ <template v-if="userListValid">
+ <gl-dropdown-item
+ :active="!userName"
+ active-class="is-active"
+ @click="updateAlertAssignees('')"
+ >
+ {{ __('Unassigned') }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
- <gl-loading-icon v-if="isUpdating" :inline="true" />
- <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
- <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users">
- <span class="gl-relative mr-2">
- <img
- :alt="userName"
- :src="userImg"
- :width="32"
- class="avatar avatar-inline gl-m-0 s32"
- data-qa-selector="avatar_image"
+ <gl-dropdown-section-header>
+ {{ __('Assignee') }}
+ </gl-dropdown-section-header>
+ <sidebar-assignee
+ v-for="user in sortedUsers"
+ :key="user.username"
+ :user="user"
+ :active="user.active"
+ @update-alert-assignees="updateAlertAssignees"
/>
- </span>
- <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">
- <strong class="dropdown-menu-user-full-name">
- {{ userFullName }}
- </strong>
- <span class="dropdown-menu-user-username">{{ userName }}</span>
- </span>
+ </template>
+ <p v-else-if="userListEmpty" class="gl-mx-5 gl-my-4">
+ {{ __('No Matching Results') }}
+ </p>
+ <gl-loading-icon v-else />
</div>
- <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal">
- {{ __('None') }} -
- <gl-button
- class="gl-ml-2"
- href="#"
- variant="link"
- data-testid="unassigned-users"
- @click="updateAlertAssignees(currentUser)"
- >
- {{ __('assign yourself') }}
- </gl-button>
+ </gl-dropdown>
+ </div>
+
+ <gl-loading-icon v-if="isUpdating" :inline="true" />
+ <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
+ <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users">
+ <span class="gl-relative gl-mr-4">
+ <img
+ :alt="userName"
+ :src="userImg"
+ :width="32"
+ class="avatar avatar-inline gl-m-0 s32"
+ data-qa-selector="avatar_image"
+ />
+ </span>
+ <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">
+ <strong class="dropdown-menu-user-full-name">
+ {{ userFullName }}
+ </strong>
+ <span class="dropdown-menu-user-username">@{{ userName }}</span>
</span>
</div>
+ <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal">
+ {{ __('None') }} -
+ <gl-button
+ class="gl-ml-2"
+ href="#"
+ variant="link"
+ data-testid="unassigned-users"
+ @click="updateAlertAssignees(currentUser)"
+ >
+ {{ __('assign yourself') }}
+ </gl-button>
+ </span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
index 12c0409629f..cf16750dbf8 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_integrations_list.vue
@@ -11,7 +11,6 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Tracking from '~/tracking';
import {
trackAlertIntegrationsViewsOptions,
@@ -54,7 +53,6 @@ export default {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
integrations: {
type: Array,
@@ -170,7 +168,7 @@ export default {
</template>
<template #cell(actions)="{ item }">
- <gl-button-group v-if="glFeatures.httpIntegrationsList" class="gl-ml-3">
+ <gl-button-group class="gl-ml-3">
<gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" />
<gl-button
v-gl-modal.deleteIntegration
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index 3656fc4d7ec..b2be563522a 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_new.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -32,6 +32,75 @@ import {
// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
import mockedCustomMapping from './mocks/parsedMapping.json';
+export const i18n = {
+ integrationFormSteps: {
+ step1: {
+ label: s__('AlertSettings|1. Select integration type'),
+ enterprise: s__(
+ 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
+ ),
+ },
+ step2: {
+ label: s__('AlertSettings|2. Name integration'),
+ placeholder: s__('AlertSettings|Enter integration name'),
+ prometheus: s__('AlertSettings|Prometheus'),
+ },
+ step3: {
+ label: s__('AlertSettings|3. Set up webhook'),
+ help: s__(
+ "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
+ ),
+ prometheusHelp: s__(
+ 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
+ ),
+ info: s__('AlertSettings|Authorization key'),
+ reset: s__('AlertSettings|Reset Key'),
+ },
+ step4: {
+ label: s__('AlertSettings|4. Sample alert payload (optional)'),
+ help: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).',
+ ),
+ prometheusHelp: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).',
+ ),
+ placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
+ resetHeader: s__('AlertSettings|Reset the mapping'),
+ resetBody: s__(
+ "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
+ ),
+ resetOk: s__('AlertSettings|Proceed with editing'),
+ editPayload: s__('AlertSettings|Edit payload'),
+ submitPayload: s__('AlertSettings|Submit payload'),
+ payloadParsedSucessMsg: s__(
+ 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
+ ),
+ },
+ step5: {
+ label: s__('AlertSettings|5. Map fields (optional)'),
+ intro: s__(
+ "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.",
+ ),
+ },
+ prometheusFormUrl: {
+ label: s__('AlertSettings|Prometheus API base URL'),
+ help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ },
+ restKeyInfo: {
+ label: s__(
+ 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
+ ),
+ },
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ opsgenie: {
+ label: s__('AlertSettings|2. Add link to your Opsgenie alert list'),
+ info: s__(
+ 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.',
+ ),
+ },
+ },
+};
+
export default {
placeholders: {
prometheus: targetPrometheusUrlPlaceholder,
@@ -39,73 +108,7 @@ export default {
},
JSON_VALIDATE_DELAY,
typeSet,
- i18n: {
- integrationFormSteps: {
- step1: {
- label: s__('AlertSettings|1. Select integration type'),
- enterprise: s__(
- 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
- ),
- },
- step2: {
- label: s__('AlertSettings|2. Name integration'),
- placeholder: s__('AlertSettings|Enter integration name'),
- },
- step3: {
- label: s__('AlertSettings|3. Set up webhook'),
- help: s__(
- "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
- ),
- prometheusHelp: s__(
- 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
- ),
- info: s__('AlertSettings|Authorization key'),
- reset: s__('AlertSettings|Reset Key'),
- },
- step4: {
- label: s__('AlertSettings|4. Sample alert payload (optional)'),
- help: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).',
- ),
- prometheusHelp: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).',
- ),
- placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
- resetHeader: s__('AlertSettings|Reset the mapping'),
- resetBody: s__(
- "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
- ),
- resetOk: s__('AlertSettings|Proceed with editing'),
- editPayload: s__('AlertSettings|Edit payload'),
- submitPayload: s__('AlertSettings|Submit payload'),
- payloadParsedSucessMsg: s__(
- 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
- ),
- },
- step5: {
- label: s__('AlertSettings|5. Map fields (optional)'),
- intro: s__(
- "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.",
- ),
- },
- prometheusFormUrl: {
- label: s__('AlertSettings|Prometheus API base URL'),
- help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
- },
- restKeyInfo: {
- label: s__(
- 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
- ),
- },
- // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
- opsgenie: {
- label: s__('AlertSettings|2. Add link to your Opsgenie alert list'),
- info: s__(
- 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.',
- ),
- },
- },
- },
+ i18n,
components: {
ClipboardButton,
GlButton,
@@ -216,8 +219,12 @@ export default {
return {
name: this.currentIntegration?.name || '',
active: this.currentIntegration?.active || false,
- token: this.currentIntegration?.token || this.selectedIntegrationType.token,
- url: this.currentIntegration?.url || this.selectedIntegrationType.url,
+ 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 || '',
};
},
@@ -246,8 +253,23 @@ export default {
canEditPayload() {
return this.hasSamplePayload && !this.resetSamplePayloadConfirmed;
},
+ isResetAuthKeyDisabled() {
+ return !this.active && !this.integrationForm.token !== '';
+ },
isPayloadEditDisabled() {
- return !this.active || this.canEditPayload;
+ return this.glFeatures.multipleHttpIntegrationsCustomMapping
+ ? !this.active || this.canEditPayload
+ : !this.active;
+ },
+ isSubmitTestPayloadDisabled() {
+ return (
+ !this.active ||
+ Boolean(this.integrationTestPayload.error) ||
+ this.integrationTestPayload.json === ''
+ );
+ },
+ isSelectDisabled() {
+ return this.currentIntegration !== null || !this.canAddIntegration;
},
},
watch: {
@@ -257,7 +279,7 @@ export default {
}
this.selectedIntegration = val.type;
this.active = val.active;
- if (val.type === typeSet.http) this.getIntegrationMapping(val.id);
+ if (val.type === typeSet.http && this.showMappingBuilder) this.getIntegrationMapping(val.id);
return this.integrationTypeSelect();
},
},
@@ -297,14 +319,8 @@ export default {
});
},
submitWithTestPayload() {
- return service
- .updateTestAlert(this.testAlertPayload)
- .then(() => {
- this.submit();
- })
- .catch(() => {
- this.$emit('test-payload-failure');
- });
+ this.$emit('set-test-alert-payload', this.testAlertPayload);
+ this.submit();
},
submit() {
// TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
@@ -323,6 +339,7 @@ export default {
return this.$emit('update-integration', integrationPayload);
}
+ this.reset();
return this.$emit('create-new-integration', integrationPayload);
},
reset() {
@@ -410,7 +427,8 @@ export default {
>
<gl-form-select
v-model="selectedIntegration"
- :disabled="currentIntegration !== null || !canAddIntegration"
+ :disabled="isSelectDisabled"
+ :class="{ 'gl-bg-gray-100!': isSelectDisabled }"
:options="options"
@change="integrationTypeSelect"
/>
@@ -461,8 +479,13 @@ export default {
>
<gl-form-input
v-model="integrationForm.name"
+ :disabled="isPrometheus"
type="text"
- :placeholder="$options.i18n.integrationFormSteps.step2.placeholder"
+ :placeholder="
+ isPrometheus
+ ? $options.i18n.integrationFormSteps.step2.prometheus
+ : $options.i18n.integrationFormSteps.step2.placeholder
+ "
/>
</gl-form-group>
<gl-form-group
@@ -539,7 +562,7 @@ export default {
</template>
</gl-form-input-group>
- <gl-button v-gl-modal.authKeyModal :disabled="!active">
+ <gl-button v-gl-modal.authKeyModal :disabled="isResetAuthKeyDisabled">
{{ $options.i18n.integrationFormSteps.step3.reset }}
</gl-button>
<gl-modal
@@ -642,7 +665,7 @@ export default {
<gl-button
v-if="!isManagingOpsgenie"
data-testid="integration-test-and-submit"
- :disabled="Boolean(integrationTestPayload.error)"
+ :disabled="isSubmitTestPayloadDisabled"
category="secondary"
variant="success"
class="gl-mx-3 js-no-auto-disable"
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue
deleted file mode 100644
index 0246315bdc5..00000000000
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form_old.vue
+++ /dev/null
@@ -1,494 +0,0 @@
-<script>
-import {
- GlAlert,
- GlButton,
- GlForm,
- GlFormGroup,
- GlFormInput,
- GlFormInputGroup,
- GlFormTextarea,
- GlLink,
- GlModal,
- GlModalDirective,
- GlSprintf,
- GlFormSelect,
-} from '@gitlab/ui';
-import { debounce } from 'lodash';
-import { doesHashExistInUrl } from '~/lib/utils/url_utility';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import ToggleButton from '~/vue_shared/components/toggle_button.vue';
-import csrf from '~/lib/utils/csrf';
-import service from '../services';
-import {
- i18n,
- integrationTypes,
- JSON_VALIDATE_DELAY,
- targetPrometheusUrlPlaceholder,
- targetOpsgenieUrlPlaceholder,
- sectionHash,
-} from '../constants';
-import createFlash, { FLASH_TYPES } from '~/flash';
-
-export default {
- i18n,
- csrf,
- targetOpsgenieUrlPlaceholder,
- targetPrometheusUrlPlaceholder,
- components: {
- GlAlert,
- GlButton,
- GlForm,
- GlFormGroup,
- GlFormInput,
- GlFormInputGroup,
- GlFormSelect,
- GlFormTextarea,
- GlLink,
- GlModal,
- GlSprintf,
- ClipboardButton,
- ToggleButton,
- },
- directives: {
- 'gl-modal': GlModalDirective,
- },
- inject: ['prometheus', 'generic', 'opsgenie'],
- data() {
- return {
- loading: false,
- selectedIntegration: integrationTypes[0].value,
- options: integrationTypes,
- active: false,
- token: '',
- targetUrl: '',
- feedback: {
- variant: 'danger',
- feedbackMessage: '',
- isFeedbackDismissed: false,
- },
- testAlert: {
- json: null,
- error: null,
- },
- canSaveForm: false,
- serverError: null,
- };
- },
- computed: {
- sections() {
- return [
- {
- text: this.$options.i18n.usageSection,
- url: this.generic.alertsUsageUrl,
- },
- {
- text: this.$options.i18n.setupSection,
- url: this.generic.alertsSetupUrl,
- },
- ];
- },
- isPrometheus() {
- return this.selectedIntegration === 'PROMETHEUS';
- },
- isOpsgenie() {
- return this.selectedIntegration === 'OPSGENIE';
- },
- selectedIntegrationType() {
- switch (this.selectedIntegration) {
- case 'HTTP': {
- return {
- url: this.generic.url,
- token: this.generic.token,
- active: this.generic.active,
- resetKey: this.resetKey.bind(this),
- };
- }
- case 'PROMETHEUS': {
- return {
- url: this.prometheus.url,
- token: this.prometheus.token,
- active: this.prometheus.active,
- resetKey: this.resetKey.bind(this, 'PROMETHEUS'),
- targetUrl: this.prometheus.prometheusApiUrl,
- };
- }
- case 'OPSGENIE': {
- return {
- targetUrl: this.opsgenie.opsgenieMvcTargetUrl,
- active: this.opsgenie.active,
- };
- }
- default: {
- return {};
- }
- }
- },
- showFeedbackMsg() {
- return this.feedback.feedbackMessage && !this.isFeedbackDismissed;
- },
- showAlertSave() {
- return (
- this.feedback.feedbackMessage === this.$options.i18n.testAlertFailed &&
- !this.isFeedbackDismissed
- );
- },
- prometheusInfo() {
- return this.isPrometheus ? this.$options.i18n.prometheusInfo : '';
- },
- jsonIsValid() {
- return this.testAlert.error === null;
- },
- canTestAlert() {
- return this.active && this.testAlert.json !== null;
- },
- canSaveConfig() {
- return !this.loading && this.canSaveForm;
- },
- baseUrlPlaceholder() {
- return this.isOpsgenie
- ? this.$options.targetOpsgenieUrlPlaceholder
- : this.$options.targetPrometheusUrlPlaceholder;
- },
- },
- watch: {
- 'testAlert.json': debounce(function debouncedJsonValidate() {
- this.validateJson();
- }, JSON_VALIDATE_DELAY),
- targetUrl(oldVal, newVal) {
- if (newVal && oldVal !== this.selectedIntegrationType.targetUrl) {
- this.canSaveForm = true;
- }
- },
- },
- mounted() {
- if (this.prometheus.active || this.generic.active || !this.opsgenie.opsgenieMvcIsAvailable) {
- this.removeOpsGenieOption();
- } else if (this.opsgenie.active) {
- this.setOpsgenieAsDefault();
- }
- this.active = this.selectedIntegrationType.active;
- this.token = this.selectedIntegrationType.token ?? '';
- },
- methods: {
- createUserErrorMessage(errors = {}) {
- const error = Object.entries(errors)?.[0];
- if (error) {
- const [field, [msg]] = error;
- this.serverError = `${field} ${msg}`;
- }
- },
- setOpsgenieAsDefault() {
- this.options = this.options.map(el => {
- if (el.value !== 'OPSGENIE') {
- return { ...el, disabled: true };
- }
- return { ...el, disabled: false };
- });
- this.selectedIntegration = this.options.find(({ value }) => value === 'OPSGENIE').value;
- if (this.targetUrl === null) {
- this.targetUrl = this.selectedIntegrationType.targetUrl;
- }
- },
- removeOpsGenieOption() {
- this.options = this.options.map(el => {
- if (el.value !== 'OPSGENIE') {
- return { ...el, disabled: false };
- }
- return { ...el, disabled: true };
- });
- },
- resetFormValues() {
- this.testAlert.json = null;
- this.targetUrl = this.selectedIntegrationType.targetUrl;
- this.active = this.selectedIntegrationType.active;
- },
- dismissFeedback() {
- this.serverError = null;
- this.feedback = { ...this.feedback, feedbackMessage: null };
- this.isFeedbackDismissed = false;
- },
- resetKey(key) {
- const fn = key === 'PROMETHEUS' ? this.resetPrometheusKey() : this.resetGenericKey();
-
- return fn
- .then(({ data: { token } }) => {
- this.token = token;
- this.setFeedback({ feedbackMessage: this.$options.i18n.tokenRest, variant: 'success' });
- })
- .catch(() => {
- this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' });
- });
- },
- resetGenericKey() {
- this.dismissFeedback();
- return service.updateGenericKey({
- endpoint: this.generic.formPath,
- params: { service: { token: '' } },
- });
- },
- resetPrometheusKey() {
- return service.updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath });
- },
- toggleService(value) {
- this.canSaveForm = true;
- this.active = value;
- },
- toggle(value) {
- return this.isPrometheus ? this.togglePrometheusActive(value) : this.toggleActivated(value);
- },
- toggleActivated(value) {
- this.loading = true;
- const path = this.isOpsgenie ? this.opsgenie.formPath : this.generic.formPath;
- return service
- .updateGenericActive({
- endpoint: path,
- params: this.isOpsgenie
- ? { service: { opsgenie_mvc_target_url: this.targetUrl, opsgenie_mvc_enabled: value } }
- : { service: { active: value } },
- })
- .then(() => this.notifySuccessAndReload())
- .catch(({ response: { data: { errors } = {} } = {} }) => {
- this.createUserErrorMessage(errors);
- this.setFeedback({
- feedbackMessage: this.$options.i18n.errorMsg,
- variant: 'danger',
- });
- })
- .finally(() => {
- this.loading = false;
- this.canSaveForm = false;
- });
- },
- reload() {
- if (!doesHashExistInUrl(sectionHash)) {
- window.location.hash = sectionHash;
- }
- window.location.reload();
- },
- togglePrometheusActive(value) {
- this.loading = true;
- return service
- .updatePrometheusActive({
- endpoint: this.prometheus.prometheusFormPath,
- params: {
- token: this.$options.csrf.token,
- config: value,
- url: this.targetUrl,
- redirect: window.location,
- },
- })
- .then(() => this.notifySuccessAndReload())
- .catch(({ response: { data: { errors } = {} } = {} }) => {
- this.createUserErrorMessage(errors);
- this.setFeedback({
- feedbackMessage: this.$options.i18n.errorMsg,
- variant: 'danger',
- });
- })
- .finally(() => {
- this.loading = false;
- this.canSaveForm = false;
- });
- },
- notifySuccessAndReload() {
- createFlash({ message: this.$options.i18n.changesSaved, type: FLASH_TYPES.NOTICE });
- setTimeout(() => this.reload(), 1000);
- },
- setFeedback({ feedbackMessage, variant }) {
- this.feedback = { feedbackMessage, variant };
- },
- validateJson() {
- this.testAlert.error = null;
- try {
- JSON.parse(this.testAlert.json);
- } catch (e) {
- this.testAlert.error = JSON.stringify(e.message);
- }
- },
- validateTestAlert() {
- this.loading = true;
- this.dismissFeedback();
- this.validateJson();
- return service
- .updateTestAlert({
- endpoint: this.selectedIntegrationType.url,
- data: this.testAlert.json,
- token: this.selectedIntegrationType.token,
- })
- .then(() => {
- this.setFeedback({
- feedbackMessage: this.$options.i18n.testAlertSuccess,
- variant: 'success',
- });
- })
- .catch(() => {
- this.setFeedback({
- feedbackMessage: this.$options.i18n.testAlertFailed,
- variant: 'danger',
- });
- })
- .finally(() => {
- this.loading = false;
- });
- },
- onSubmit() {
- this.dismissFeedback();
- this.toggle(this.active);
- },
- onReset() {
- this.testAlert.json = null;
- this.dismissFeedback();
- this.targetUrl = this.selectedIntegrationType.targetUrl;
-
- if (this.canSaveForm) {
- this.canSaveForm = false;
- this.active = this.selectedIntegrationType.active;
- }
- },
- },
-};
-</script>
-
-<template>
- <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
- <h5 class="gl-font-lg gl-my-5">{{ $options.i18n.integrationsLabel }}</h5>
-
- <gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback">
- {{ feedback.feedbackMessage }}
- <br />
- <i v-if="serverError">{{ __('Error message:') }} {{ serverError }}</i>
- <gl-button
- v-if="showAlertSave"
- variant="danger"
- category="primary"
- class="gl-display-block gl-mt-3"
- @click="toggle(active)"
- >
- {{ __('Save anyway') }}
- </gl-button>
- </gl-alert>
-
- <div data-testid="alert-settings-description">
- <p v-for="section in sections" :key="section.text">
- <gl-sprintf :message="section.text">
- <template #link="{ content }">
- <gl-link :href="section.url" target="_blank">{{ content }}</gl-link>
- </template>
- </gl-sprintf>
- </p>
- </div>
-
- <gl-form-group label-for="integration-type" :label="$options.i18n.integration">
- <gl-form-select
- id="integration-type"
- v-model="selectedIntegration"
- :options="options"
- data-testid="alert-settings-select"
- @change="resetFormValues"
- />
- <span class="gl-text-gray-500">
- <gl-sprintf :message="$options.i18n.integrationsInfo">
- <template #link="{ content }">
- <gl-link
- class="gl-display-inline-block"
- href="https://gitlab.com/groups/gitlab-org/-/epics/4390"
- target="_blank"
- >{{ content }}</gl-link
- >
- </template>
- </gl-sprintf>
- </span>
- </gl-form-group>
- <gl-form-group :label="$options.i18n.activeLabel" label-for="active">
- <toggle-button
- id="active"
- :disabled-input="loading"
- :is-loading="loading"
- :value="active"
- @change="toggleService"
- />
- </gl-form-group>
- <gl-form-group
- v-if="isOpsgenie || isPrometheus"
- :label="$options.i18n.apiBaseUrlLabel"
- label-for="api-url"
- >
- <gl-form-input
- id="api-url"
- v-model="targetUrl"
- type="url"
- :placeholder="baseUrlPlaceholder"
- :disabled="!active"
- />
- <span class="gl-text-gray-500">
- {{ $options.i18n.apiBaseUrlHelpText }}
- </span>
- </gl-form-group>
- <template v-if="!isOpsgenie">
- <gl-form-group :label="$options.i18n.urlLabel" label-for="url">
- <gl-form-input-group id="url" readonly :value="selectedIntegrationType.url">
- <template #append>
- <clipboard-button
- :text="selectedIntegrationType.url"
- :title="$options.i18n.copyToClipboard"
- class="gl-m-0!"
- />
- </template>
- </gl-form-input-group>
- <span class="gl-text-gray-500">
- {{ prometheusInfo }}
- </span>
- </gl-form-group>
- <gl-form-group :label="$options.i18n.tokenLabel" label-for="authorization-key">
- <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="token">
- <template #append>
- <clipboard-button
- :text="token"
- :title="$options.i18n.copyToClipboard"
- class="gl-m-0!"
- />
- </template>
- </gl-form-input-group>
- <gl-button v-gl-modal.tokenModal :disabled="!active" class="gl-mt-3">{{
- $options.i18n.resetKey
- }}</gl-button>
- <gl-modal
- modal-id="tokenModal"
- :title="$options.i18n.resetKey"
- :ok-title="$options.i18n.resetKey"
- ok-variant="danger"
- @ok="selectedIntegrationType.resetKey"
- >
- {{ $options.i18n.restKeyInfo }}
- </gl-modal>
- </gl-form-group>
- <gl-form-group
- :label="$options.i18n.alertJson"
- label-for="alert-json"
- :invalid-feedback="testAlert.error"
- >
- <gl-form-textarea
- id="alert-json"
- v-model.trim="testAlert.json"
- :disabled="!active"
- :state="jsonIsValid"
- :placeholder="$options.i18n.alertJsonPlaceholder"
- rows="6"
- max-rows="10"
- />
- </gl-form-group>
-
- <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{
- $options.i18n.testAlertInfo
- }}</gl-button>
- </template>
- <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between">
- <gl-button variant="success" category="primary" :disabled="!canSaveConfig" @click="onSubmit">
- {{ __('Save changes') }}
- </gl-button>
- <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset">
- {{ __('Cancel') }}
- </gl-button>
- </div>
- </gl-form>
-</template>
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
index 1ffc2f80148..a55e63c3bc0 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_wrapper.vue
@@ -1,7 +1,6 @@
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
@@ -15,8 +14,8 @@ import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutati
import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql';
import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql';
import IntegrationsList from './alerts_integrations_list.vue';
-import SettingsFormOld from './alerts_settings_form_old.vue';
-import SettingsFormNew from './alerts_settings_form_new.vue';
+import AlertSettingsForm from './alerts_settings_form.vue';
+import service from '../services';
import { typeSet } from '../constants';
import {
updateStoreAfterIntegrationDelete,
@@ -37,6 +36,9 @@ export default {
'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.',
+ ),
},
components: {
// TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
@@ -44,10 +46,8 @@ export default {
GlLink,
GlSprintf,
IntegrationsList,
- SettingsFormOld,
- SettingsFormNew,
+ AlertSettingsForm,
},
- mixins: [glFeatureFlagsMixin()],
inject: {
generic: {
default: {},
@@ -93,6 +93,7 @@ export default {
data() {
return {
isUpdating: false,
+ testAlertPayload: null,
integrations: {},
currentIntegration: null,
};
@@ -101,25 +102,12 @@ export default {
loading() {
return this.$apollo.queries.integrations.loading;
},
- integrationsOptionsOld() {
- return [
- {
- name: s__('AlertSettings|HTTP endpoint'),
- type: s__('AlertsIntegrations|HTTP endpoint'),
- active: this.generic.active,
- },
- {
- name: s__('AlertSettings|External Prometheus'),
- type: s__('AlertsIntegrations|Prometheus'),
- active: this.prometheus.active,
- },
- ];
- },
canAddIntegration() {
return this.multiIntegrations || this.integrations?.list?.length < 2;
},
canManageOpsgenie() {
return (
+ this.opsgenie.active ||
this.integrations?.list?.every(({ active }) => active === false) ||
this.integrations?.list?.length === 0
);
@@ -149,6 +137,19 @@ export default {
if (error) {
return createFlash({ message: error });
}
+
+ if (this.testAlertPayload) {
+ const integration =
+ httpIntegrationCreate?.integration || prometheusIntegrationCreate?.integration;
+
+ const payload = {
+ ...this.testAlertPayload,
+ endpoint: integration.url,
+ token: integration.token,
+ };
+ return this.validateAlertPayload(payload);
+ }
+
return createFlash({
message: this.$options.i18n.changesSaved,
type: FLASH_TYPES.SUCCESS,
@@ -179,6 +180,13 @@ export default {
if (error) {
return createFlash({ message: error });
}
+
+ if (this.testAlertPayload) {
+ return this.validateAlertPayload();
+ }
+
+ this.clearCurrentIntegration();
+
return createFlash({
message: this.$options.i18n.changesSaved,
type: FLASH_TYPES.SUCCESS,
@@ -189,6 +197,7 @@ export default {
})
.finally(() => {
this.isUpdating = false;
+ this.testAlertPayload = null;
});
},
resetToken({ type, variables }) {
@@ -212,7 +221,13 @@ export default {
const integration =
httpIntegrationResetToken?.integration ||
prometheusIntegrationResetToken?.integration;
- this.currentIntegration = integration;
+
+ this.$apollo.mutate({
+ mutation: updateCurrentIntergrationMutation,
+ variables: {
+ ...integration,
+ },
+ });
return createFlash({
message: this.$options.i18n.changesSaved,
@@ -280,8 +295,21 @@ export default {
variables: {},
});
},
- testPayloadFailure() {
- createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
+ setTestAlertPayload(payload) {
+ this.testAlertPayload = payload;
+ },
+ validateAlertPayload(payload) {
+ return service
+ .updateTestAlert(payload ?? this.testAlertPayload)
+ .then(() => {
+ return createFlash({
+ message: this.$options.i18n.alertSent,
+ type: FLASH_TYPES.SUCCESS,
+ });
+ })
+ .catch(() => {
+ createFlash({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
+ });
},
},
};
@@ -310,13 +338,12 @@ export default {
</gl-alert>
<integrations-list
v-else
- :integrations="glFeatures.httpIntegrationsList ? integrations.list : integrationsOptionsOld"
+ :integrations="integrations.list"
:loading="loading"
@edit-integration="editIntegration"
@delete-integration="deleteIntegration"
/>
- <settings-form-new
- v-if="glFeatures.httpIntegrationsList"
+ <alert-settings-form
:loading="isUpdating"
:can-add-integration="canAddIntegration"
:can-manage-opsgenie="canManageOpsgenie"
@@ -324,8 +351,7 @@ export default {
@update-integration="updateIntegration"
@reset-token="resetToken"
@clear-current-integration="clearCurrentIntegration"
- @test-payload-failure="testPayloadFailure"
+ @set-test-alert-payload="setTestAlertPayload"
/>
- <settings-form-old v-else />
</div>
</template>
diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js
index 1835d6b46aa..e45ea772ddd 100644
--- a/app/assets/javascripts/alerts_settings/services/index.js
+++ b/app/assets/javascripts/alerts_settings/services/index.js
@@ -2,30 +2,9 @@
import axios from '~/lib/utils/axios_utils';
export default {
- // TODO: All this code save updateTestAlert will be deleted as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/255501
- updateGenericKey({ endpoint, params }) {
- return axios.put(endpoint, params);
- },
- updatePrometheusKey({ endpoint }) {
- return axios.post(endpoint);
- },
updateGenericActive({ endpoint, params }) {
return axios.put(endpoint, params);
},
- updatePrometheusActive({ endpoint, params: { token, config, url, redirect } }) {
- const data = new FormData();
- data.set('_method', 'put');
- data.set('authenticity_token', token);
- data.set('service[manual_configuration]', config);
- data.set('service[api_url]', url);
- data.set('redirect_to', redirect);
-
- return axios.post(endpoint, data, {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded',
- },
- });
- },
updateTestAlert({ endpoint, data, token }) {
return axios.post(endpoint, data, {
headers: {
diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql
deleted file mode 100644
index 40cef95c2e7..00000000000
--- a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql
+++ /dev/null
@@ -1,4 +0,0 @@
-fragment Count on InstanceStatisticsMeasurement {
- count
- recordedAt
-}
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index f469f49ce20..8daccae3467 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -69,6 +69,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',
+ usageDataIncrementCounterPath: '/api/:version/usage_data/increment_counter',
usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users',
featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists',
featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid',
@@ -389,7 +390,10 @@ const Api = {
params: { ...defaults, ...options },
})
.then(({ data }) => callback(data))
- .catch(() => flash(__('Something went wrong while fetching projects')));
+ .catch(() => {
+ flash(__('Something went wrong while fetching projects'));
+ callback();
+ });
},
commit(id, sha, params = {}) {
@@ -751,6 +755,19 @@ const Api = {
return axios.post(url, freezePeriod);
},
+ trackRedisCounterEvent(event) {
+ if (!gon.features?.usageDataApi) {
+ return null;
+ }
+
+ const url = Api.buildUrl(this.usageDataIncrementCounterPath);
+ const headers = {
+ 'Content-Type': 'application/json',
+ };
+
+ return axios.post(url, { event }, { headers });
+ },
+
trackRedisHllUserEvent(event) {
if (!gon.features?.usageDataApi) {
return null;
diff --git a/app/assets/javascripts/authentication/mount_2fa.js b/app/assets/javascripts/authentication/mount_2fa.js
index dd5a42fa5fc..6dead2f03db 100644
--- a/app/assets/javascripts/authentication/mount_2fa.js
+++ b/app/assets/javascripts/authentication/mount_2fa.js
@@ -13,11 +13,17 @@ export const mount2faAuthentication = () => {
};
export const mount2faRegistration = () => {
+ const el = $('#js-register-token-2fa');
+
+ if (!el.length) {
+ return;
+ }
+
if (gon.webauthn) {
- const webauthnRegister = new WebAuthnRegister($('#js-register-token-2fa'), gon.webauthn);
+ const webauthnRegister = new WebAuthnRegister(el, gon.webauthn);
webauthnRegister.start();
} else {
- const u2fRegister = new U2FRegister($('#js-register-token-2fa'), gon.u2f);
+ const u2fRegister = new U2FRegister(el, gon.u2f);
u2fRegister.start();
}
};
diff --git a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
new file mode 100644
index 00000000000..87502db8b82
--- /dev/null
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -0,0 +1,174 @@
+<script>
+import Mousetrap from 'mousetrap';
+import { GlSprintf, GlButton, GlAlert } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import Tracking from '~/tracking';
+import { __ } from '~/locale';
+import {
+ COPY_BUTTON_ACTION,
+ DOWNLOAD_BUTTON_ACTION,
+ PRINT_BUTTON_ACTION,
+ TRACKING_LABEL_PREFIX,
+ RECOVERY_CODE_DOWNLOAD_FILENAME,
+ COPY_KEYBOARD_SHORTCUT,
+} from '../constants';
+
+export const i18n = {
+ pageTitle: __('Two-factor Authentication Recovery codes'),
+ alertTitle: __('Please copy, download, or print your recovery codes before proceeding.'),
+ pageDescription: __(
+ 'Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{boldStart}will%{boldEnd} lose access to your account.',
+ ),
+ copyButton: __('Copy codes'),
+ downloadButton: __('Download codes'),
+ printButton: __('Print codes'),
+ proceedButton: __('Proceed'),
+};
+
+export default {
+ name: 'RecoveryCodes',
+ copyButtonAction: COPY_BUTTON_ACTION,
+ downloadButtonAction: DOWNLOAD_BUTTON_ACTION,
+ printButtonAction: PRINT_BUTTON_ACTION,
+ trackingLabelPrefix: TRACKING_LABEL_PREFIX,
+ recoveryCodeDownloadFilename: RECOVERY_CODE_DOWNLOAD_FILENAME,
+ i18n,
+ mousetrap: null,
+ components: { GlSprintf, GlButton, GlAlert, ClipboardButton },
+ mixins: [Tracking.mixin()],
+ props: {
+ codes: {
+ type: Array,
+ required: true,
+ },
+ profileAccountPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ proceedButtonDisabled: true,
+ };
+ },
+ computed: {
+ codesAsString() {
+ return this.codes.join('\n');
+ },
+ codeDownloadUrl() {
+ return `data:text/plain;charset=utf-8,${encodeURIComponent(this.codesAsString)}`;
+ },
+ },
+ created() {
+ this.$options.mousetrap = new Mousetrap();
+
+ this.$options.mousetrap.bind(COPY_KEYBOARD_SHORTCUT, this.handleKeyboardCopy);
+ },
+ beforeDestroy() {
+ if (!this.$options.mousetrap) {
+ return;
+ }
+
+ this.$options.mousetrap.unbind(COPY_KEYBOARD_SHORTCUT);
+ },
+ methods: {
+ handleButtonClick(action) {
+ this.proceedButtonDisabled = false;
+
+ if (action === this.$options.printButtonAction) {
+ window.print();
+ }
+
+ this.track('click_button', { label: `${this.$options.trackingLabelPrefix}${action}_button` });
+ },
+ handleKeyboardCopy() {
+ if (!window.getSelection) {
+ return;
+ }
+
+ const copiedText = window.getSelection().toString();
+
+ if (copiedText.includes(this.codesAsString)) {
+ this.proceedButtonDisabled = false;
+ this.track('copy_keyboard_shortcut', {
+ label: `${this.$options.trackingLabelPrefix}manual_copy`,
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h3 class="page-title">
+ {{ $options.i18n.pageTitle }}
+ </h3>
+ <hr />
+ <gl-alert variant="info" :dismissible="false">
+ {{ $options.i18n.alertTitle }}
+ </gl-alert>
+ <p class="gl-mt-5">
+ <gl-sprintf :message="$options.i18n.pageDescription">
+ <template #bold="{ content }"
+ ><strong>{{ content }}</strong></template
+ >
+ </gl-sprintf>
+ </p>
+
+ <div
+ class="codes-to-print gl-my-5 gl-p-5 gl-border-solid gl-border-1 gl-border-gray-100 gl-rounded-base"
+ data-testid="recovery-codes"
+ data-qa-selector="codes_content"
+ >
+ <ul class="gl-m-0 gl-pl-5">
+ <li v-for="(code, index) in codes" :key="index">
+ <span class="gl-font-monospace" data-qa-selector="code_content">{{ code }}</span>
+ </li>
+ </ul>
+ </div>
+ <div class="gl-my-n2 gl-mx-n2 gl-display-flex gl-flex-wrap">
+ <div class="gl-p-2">
+ <clipboard-button
+ :title="$options.i18n.copyButton"
+ :text="codesAsString"
+ data-qa-selector="copy_button"
+ @click="handleButtonClick($options.copyButtonAction)"
+ >
+ {{ $options.i18n.copyButton }}
+ </clipboard-button>
+ </div>
+ <div class="gl-p-2">
+ <gl-button
+ :href="codeDownloadUrl"
+ :title="$options.i18n.downloadButton"
+ icon="download"
+ :download="$options.recoveryCodeDownloadFilename"
+ @click="handleButtonClick($options.downloadButtonAction)"
+ >
+ {{ $options.i18n.downloadButton }}
+ </gl-button>
+ </div>
+ <div class="gl-p-2">
+ <gl-button
+ :title="$options.i18n.printButton"
+ @click="handleButtonClick($options.printButtonAction)"
+ >
+ {{ $options.i18n.printButton }}
+ </gl-button>
+ </div>
+ <div class="gl-p-2">
+ <gl-button
+ :href="profileAccountPath"
+ :disabled="proceedButtonDisabled"
+ :title="$options.i18n.proceedButton"
+ variant="success"
+ data-qa-selector="proceed_button"
+ data-track-event="click_button"
+ :data-track-label="`${$options.trackingLabelPrefix}proceed_button`"
+ >{{ $options.i18n.proceedButton }}</gl-button
+ >
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/authentication/two_factor_auth/constants.js b/app/assets/javascripts/authentication/two_factor_auth/constants.js
new file mode 100644
index 00000000000..35fc49c88b2
--- /dev/null
+++ b/app/assets/javascripts/authentication/two_factor_auth/constants.js
@@ -0,0 +1,11 @@
+export const COPY_BUTTON_ACTION = 'copy';
+export const DOWNLOAD_BUTTON_ACTION = 'download';
+export const PRINT_BUTTON_ACTION = 'print';
+
+export const TRACKING_LABEL_PREFIX = '2fa_recovery_codes_';
+
+export const RECOVERY_CODE_DOWNLOAD_FILENAME = 'gitlab-recovery-codes.txt';
+
+export const SUCCESS_QUERY_PARAM = 'two_factor_auth_enabled_successfully';
+
+export const COPY_KEYBOARD_SHORTCUT = 'mod+c';
diff --git a/app/assets/javascripts/authentication/two_factor_auth/index.js b/app/assets/javascripts/authentication/two_factor_auth/index.js
new file mode 100644
index 00000000000..5e59c44e8cd
--- /dev/null
+++ b/app/assets/javascripts/authentication/two_factor_auth/index.js
@@ -0,0 +1,46 @@
+import Vue from 'vue';
+import { updateHistory, removeParams } from '~/lib/utils/url_utility';
+import RecoveryCodes from './components/recovery_codes.vue';
+import { SUCCESS_QUERY_PARAM } from './constants';
+
+export const initRecoveryCodes = () => {
+ const el = document.querySelector('.js-2fa-recovery-codes');
+
+ if (!el) {
+ return false;
+ }
+
+ const { codes = '[]', profileAccountPath = '' } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(RecoveryCodes, {
+ props: {
+ codes: JSON.parse(codes),
+ profileAccountPath,
+ },
+ });
+ },
+ });
+};
+
+export const initClose2faSuccessMessage = () => {
+ const closeButton = document.querySelector('.js-close-2fa-enabled-success-alert');
+
+ if (!closeButton) {
+ return;
+ }
+
+ closeButton.addEventListener(
+ 'click',
+ () => {
+ updateHistory({
+ url: removeParams([SUCCESS_QUERY_PARAM]),
+ title: document.title,
+ replace: true,
+ });
+ },
+ { once: true },
+ );
+};
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 5f50fcc112e..0a05e0d44ce 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -74,6 +74,7 @@ export default class Autosave {
}
dispose() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.field.off('input');
}
}
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 17e6255700a..d937060536a 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -596,6 +596,7 @@ export class AwardsHandler {
hideMenuElement($emojiMenu) {
$emojiMenu.on(transitionEndEventString, e => {
if (e.currentTarget === e.target) {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$emojiMenu.removeClass(IS_RENDERED).off(transitionEndEventString);
}
});
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index 0b8c6aff219..c3512773457 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -84,7 +84,7 @@ export default {
<div v-show="hasError" class="btn-group">
<div class="btn btn-default btn-sm disabled">
- <gl-icon :size="16" class="gl-ml-3 gl-mr-3" name="doc-image" aria-hidden="true" />
+ <gl-icon :size="16" class="gl-ml-3 gl-mr-3" name="doc-image" />
</div>
<div class="btn btn-default btn-sm disabled">
<span class="gl-ml-3 gl-mr-3">{{ s__('Badges|No badge image') }}</span>
diff --git a/app/assets/javascripts/behaviors/select2.js b/app/assets/javascripts/behaviors/select2.js
index 37b75bb5e56..1f222d8c1f6 100644
--- a/app/assets/javascripts/behaviors/select2.js
+++ b/app/assets/javascripts/behaviors/select2.js
@@ -1,22 +1,29 @@
import $ from 'jquery';
+import { loadCSSFile } from '../lib/utils/css_utils';
export default () => {
- if ($('select.select2').length) {
+ const $select2Elements = $('select.select2');
+ if ($select2Elements.length) {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
- $('select.select2').select2({
- width: 'resolve',
- minimumResultsForSearch: 10,
- dropdownAutoWidth: true,
- });
+ // eslint-disable-next-line promise/no-nesting
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ $select2Elements.select2({
+ width: 'resolve',
+ minimumResultsForSearch: 10,
+ dropdownAutoWidth: true,
+ });
- // Close select2 on escape
- $('.js-select2').on('select2-close', () => {
- setTimeout(() => {
- $('.select2-container-active').removeClass('select2-container-active');
- $(':focus').blur();
- }, 1);
- });
+ // Close select2 on escape
+ $('.js-select2').on('select2-close', () => {
+ requestAnimationFrame(() => {
+ $('.select2-container-active').removeClass('select2-container-active');
+ $(':focus').blur();
+ });
+ });
+ })
+ .catch(() => {});
})
.catch(() => {});
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index a53150f8d61..c0f67923191 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -97,6 +97,7 @@ export default class Shortcuts {
e.preventDefault();
});
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.js-shortcuts-modal-trigger')
.off('click')
.on('click', this.onToggleHelp);
diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
index 902dd0b8eec..a5b594fbd88 100644
--- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
+++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
@@ -50,7 +50,6 @@ export default {
:aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE"
:title="$options.SIMPLE_BLOB_VIEWER_TITLE"
:selected="isSimpleViewer"
- :class="{ active: isSimpleViewer }"
icon="code"
category="primary"
variant="default"
@@ -61,7 +60,6 @@ export default {
:aria-label="$options.RICH_BLOB_VIEWER_TITLE"
:title="$options.RICH_BLOB_VIEWER_TITLE"
:selected="isRichViewer"
- :class="{ active: isRichViewer }"
icon="document"
category="primary"
variant="default"
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index 5058ca7122d..8f64bda1ba6 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -82,7 +82,6 @@ export default class FileTemplateMediator {
initPageEvents() {
this.listenForFilenameInput();
- this.prepFileContentForSubmit();
this.listenForPreviewMode();
}
@@ -92,12 +91,6 @@ export default class FileTemplateMediator {
});
}
- prepFileContentForSubmit() {
- this.$commitForm.submit(() => {
- this.$fileContent.val(this.editor.getValue());
- });
- }
-
listenForPreviewMode() {
this.$navLinks.on('click', 'a', e => {
const urlPieces = e.target.href.split('#');
diff --git a/app/assets/javascripts/blob/file_template_selector.js b/app/assets/javascripts/blob/file_template_selector.js
index bd39aa2e16f..2532aeea989 100644
--- a/app/assets/javascripts/blob/file_template_selector.js
+++ b/app/assets/javascripts/blob/file_template_selector.js
@@ -12,7 +12,10 @@ export default class FileTemplateSelector {
this.$dropdown = $(cfg.dropdown);
this.$wrapper = $(cfg.wrapper);
- this.$loadingIcon = this.$wrapper.find('.fa-chevron-down');
+ this.$dropdownIcon = this.$wrapper.find('.dropdown-menu-toggle-icon');
+ this.$loadingIcon = $(
+ '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>',
+ ).insertAfter(this.$dropdownIcon);
this.$dropdownToggleText = this.$wrapper.find('.dropdown-toggle-text');
this.initDropdown();
@@ -45,15 +48,13 @@ export default class FileTemplateSelector {
}
renderLoading() {
- this.$loadingIcon
- .addClass('gl-spinner gl-spinner-orange gl-spinner-sm')
- .removeClass('fa-chevron-down');
+ this.$loadingIcon.removeClass('gl-display-none');
+ this.$dropdownIcon.addClass('gl-display-none');
}
renderLoaded() {
- this.$loadingIcon
- .addClass('fa-chevron-down')
- .removeClass('gl-spinner gl-spinner-orange gl-spinner-sm');
+ this.$loadingIcon.addClass('gl-display-none');
+ this.$dropdownIcon.removeClass('gl-display-none');
}
reportSelection(options) {
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 06f436adb8e..6fee40fb061 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
@@ -107,7 +107,7 @@ export default {
v-if="!popoverDismissed"
show
:target="target"
- placement="rightbottom"
+ placement="right"
trigger="manual"
container="viewport"
:css-classes="['suggest-gitlab-ci-yml', 'ml-4']"
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js
index 257458138dc..ae9bb3455f0 100644
--- a/app/assets/javascripts/blob/template_selector.js
+++ b/app/assets/javascripts/blob/template_selector.js
@@ -10,7 +10,10 @@ export default class TemplateSelector {
this.dropdown = dropdown;
this.$dropdownContainer = wrapper;
this.$filenameInput = $input || $('#file_name');
- this.$dropdownIcon = $('.fa-chevron-down', dropdown);
+ this.$dropdownIcon = $('.dropdown-menu-toggle-icon', dropdown);
+ this.$loadingIcon = $(
+ '<div class="gl-spinner gl-spinner-orange gl-spinner-sm gl-absolute gl-top-3 gl-right-3 gl-display-none"></div>',
+ ).insertAfter(this.$dropdownIcon);
this.initDropdown(dropdown, data);
this.listenForFilenameInput();
@@ -92,10 +95,12 @@ export default class TemplateSelector {
}
startLoadingSpinner() {
- this.$dropdownIcon.addClass('spinner').removeClass('fa-chevron-down');
+ this.$loadingIcon.removeClass('gl-display-none');
+ this.$dropdownIcon.addClass('gl-display-none');
}
stopLoadingSpinner() {
- this.$dropdownIcon.addClass('fa-chevron-down').removeClass('spinner');
+ this.$loadingIcon.addClass('gl-display-none');
+ this.$dropdownIcon.removeClass('gl-display-none');
}
}
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index aa76364c466..01350acad0c 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -132,16 +132,16 @@ export default class BlobViewer {
const newViewer = this.$fileHolder[0].querySelector(`.blob-viewer[data-type='${name}']`);
if (this.activeViewer === newViewer) return;
- const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active');
+ const oldButton = document.querySelector('.js-blob-viewer-switch-btn.selected');
const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`);
const oldViewer = this.$fileHolder[0].querySelector(`.blob-viewer:not([data-type='${name}'])`);
if (oldButton) {
- oldButton.classList.remove('active');
+ oldButton.classList.remove('selected');
}
if (newButton) {
- newButton.classList.add('active');
+ newButton.classList.add('selected');
newButton.blur();
}
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index f84e39baa53..678044687a9 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -38,9 +38,20 @@ const initPopovers = () => {
}
};
+export const initUploadForm = () => {
+ const uploadBlobForm = $('.js-upload-blob-form');
+ if (uploadBlobForm.length) {
+ const method = uploadBlobForm.data('method');
+
+ new BlobFileDropzone(uploadBlobForm, method);
+ new NewCommitForm(uploadBlobForm);
+
+ disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file');
+ }
+};
+
export default () => {
const editBlobForm = $('.js-edit-blob-form');
- const uploadBlobForm = $('.js-upload-blob-form');
const deleteBlobForm = $('.js-delete-blob-form');
if (editBlobForm.length) {
@@ -80,14 +91,7 @@ export default () => {
window.onbeforeunload = () => '';
}
- if (uploadBlobForm.length) {
- const method = uploadBlobForm.data('method');
-
- new BlobFileDropzone(uploadBlobForm, method);
- new NewCommitForm(uploadBlobForm);
-
- disableButtonIfEmptyField(uploadBlobForm.find('.js-commit-message'), '.btn-upload-file');
- }
+ initUploadForm();
if (deleteBlobForm.length) {
new NewCommitForm(deleteBlobForm);
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index e6b0a6fc1c5..1bc51aa1d6f 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -5,7 +5,8 @@ import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants';
import TemplateSelectorMediator from '../blob/file_template_mediator';
import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
import EditorLite from '~/editor/editor_lite';
-import FileTemplateExtension from '~/editor/editor_file_template_ext';
+import { FileTemplateExtension } from '~/editor/editor_file_template_ext';
+import { insertFinalNewline } from '~/lib/utils/text_utility';
export default class EditBlob {
// The options object has:
@@ -16,11 +17,11 @@ export default class EditBlob {
if (this.options.isMarkdown) {
import('~/editor/editor_markdown_ext')
- .then(MarkdownExtension => {
- this.editor.use(MarkdownExtension.default);
+ .then(({ EditorMarkdownExtension: MarkdownExtension } = {}) => {
+ this.editor.use(new MarkdownExtension());
addEditorMarkdownListeners(this.editor);
})
- .catch(() => createFlash(BLOB_EDITOR_ERROR));
+ .catch(e => createFlash(`${BLOB_EDITOR_ERROR}: ${e}`));
}
this.initModePanesAndLinks();
@@ -42,14 +43,14 @@ export default class EditBlob {
blobPath: fileNameEl.value,
blobContent: editorEl.innerText,
});
- this.editor.use(FileTemplateExtension);
+ this.editor.use(new FileTemplateExtension());
fileNameEl.addEventListener('change', () => {
this.editor.updateModelLanguage(fileNameEl.value);
});
form.addEventListener('submit', () => {
- fileContentEl.value = this.editor.getValue();
+ fileContentEl.value = insertFinalNewline(this.editor.getValue());
});
}
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 6b7b0c2e28d..e5ff41dab74 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,31 +1,39 @@
import { sortBy } from 'lodash';
-import ListIssue from 'ee_else_ce/boards/models/issue';
+import axios from '~/lib/utils/axios_utils';
import { ListType } from './constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import boardsStore from '~/boards/stores/boards_store';
export function getMilestone() {
return null;
}
+export function updateListPosition(listObj) {
+ const { listType } = listObj;
+ let { position } = listObj;
+ if (listType === ListType.closed) {
+ position = Infinity;
+ } else if (listType === ListType.backlog) {
+ position = -Infinity;
+ }
+
+ return { ...listObj, position };
+}
+
export function formatBoardLists(lists) {
- const formattedLists = lists.nodes.map(list =>
- boardsStore.updateListPosition({ ...list, doNotFetchIssues: true }),
- );
- return formattedLists.reduce((map, list) => {
+ return lists.nodes.reduce((map, list) => {
return {
...map,
- [list.id]: list,
+ [list.id]: updateListPosition(list),
};
}, {});
}
export function formatIssue(issue) {
- return new ListIssue({
+ return {
...issue,
labels: issue.labels?.nodes || [],
assignees: issue.assignees?.nodes || [],
- });
+ };
}
export function formatListIssues(listIssues) {
@@ -44,12 +52,12 @@ export function formatListIssues(listIssues) {
[list.id]: sortedIssues.map(i => {
const id = getIdFromGraphQLId(i.id);
- const listIssue = new ListIssue({
+ const listIssue = {
...i,
id,
labels: i.labels?.nodes || [],
assignees: i.assignees?.nodes || [],
- });
+ };
issues[id] = listIssue;
@@ -83,21 +91,48 @@ export function fullLabelId(label) {
}
export function moveIssueListHelper(issue, fromList, toList) {
- if (toList.type === ListType.label) {
- issue.addLabel(toList.label);
+ const updatedIssue = issue;
+ if (
+ toList.listType === ListType.label &&
+ !updatedIssue.labels.find(label => label.id === toList.label.id)
+ ) {
+ updatedIssue.labels.push(toList.label);
}
- if (fromList && fromList.type === ListType.label) {
- issue.removeLabel(fromList.label);
+ if (fromList?.label && fromList.listType === ListType.label) {
+ updatedIssue.labels = updatedIssue.labels.filter(label => fromList.label.id !== label.id);
}
- if (toList.type === ListType.assignee) {
- issue.addAssignee(toList.assignee);
+ if (
+ toList.listType === ListType.assignee &&
+ !updatedIssue.assignees.find(assignee => assignee.id === toList.assignee.id)
+ ) {
+ updatedIssue.assignees.push(toList.assignee);
+ }
+ if (fromList?.assignee && fromList.listType === ListType.assignee) {
+ updatedIssue.assignees = updatedIssue.assignees.filter(
+ assignee => assignee.id !== fromList.assignee.id,
+ );
}
- if (fromList && fromList.type === ListType.assignee) {
- issue.removeAssignee(fromList.assignee);
+
+ return updatedIssue;
+}
+
+export function getBoardsPath(endpoint, board) {
+ const path = `${endpoint}${board.id ? `/${board.id}` : ''}.json`;
+
+ if (board.id) {
+ return axios.put(path, { board });
}
+ return axios.post(path, { board });
+}
+
+export function isListDraggable(list) {
+ return list.listType !== ListType.backlog && list.listType !== ListType.closed;
+}
- return issue;
+// EE-specific feature. Find the implementation in the `ee/`-folder
+export function transformBoardConfig() {
+ return '';
}
export default {
@@ -106,4 +141,6 @@ export default {
formatListIssues,
fullBoardId,
fullLabelId,
+ getBoardsPath,
+ isListDraggable,
};
diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
index c81f171af2b..1469efae5a6 100644
--- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
+++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue
@@ -1,18 +1,20 @@
<script>
-import { mapActions, mapGetters } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { cloneDeep } from 'lodash';
import {
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
+ GlLoadingIcon,
} from '@gitlab/ui';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
-import searchUsers from '~/boards/queries/users_search.query.graphql';
+import searchUsers from '~/boards/graphql/users_search.query.graphql';
export default {
noSearchDelay: 0,
@@ -32,12 +34,13 @@ export default {
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
+ GlLoadingIcon,
},
data() {
return {
search: '',
participants: [],
- selected: this.$store.getters.activeIssue.assignees,
+ selected: [],
};
},
apollo: {
@@ -72,6 +75,7 @@ export default {
},
computed: {
...mapGetters(['activeIssue']),
+ ...mapState(['isSettingAssignees']),
assigneeText() {
return n__('Assignee', '%d Assignees', this.selected.length);
},
@@ -89,9 +93,20 @@ export default {
isSearchEmpty() {
return this.search === '';
},
+ currentUser() {
+ return gon?.current_username;
+ },
+ },
+ created() {
+ this.selected = cloneDeep(this.activeIssue.assignees);
},
methods: {
...mapActions(['setAssignees']),
+ async assignSelf() {
+ const [currentUserObject] = await this.setAssignees(this.currentUser);
+
+ this.selectAssignee(currentUserObject);
+ },
clearSelected() {
this.selected = [];
},
@@ -117,9 +132,9 @@ export default {
</script>
<template>
- <board-editable-item :title="assigneeText" @close="saveAssignees">
+ <board-editable-item :loading="isSettingAssignees" :title="assigneeText" @close="saveAssignees">
<template #collapsed>
- <issuable-assignees :users="activeIssue.assignees" />
+ <issuable-assignees :users="activeIssue.assignees" @assign-self="assignSelf" />
</template>
<template #default>
@@ -132,45 +147,48 @@ export default {
<gl-search-box-by-type v-model.trim="search" />
</template>
<template #items>
- <gl-dropdown-item
- :is-checked="selectedIsEmpty"
- data-testid="unassign"
- class="mt-2"
- @click="selectAssignee()"
- >{{ $options.i18n.unassigned }}</gl-dropdown-item
- >
- <gl-dropdown-divider data-testid="unassign-divider" />
- <gl-dropdown-item
- v-for="item in selected"
- :key="item.id"
- :is-checked="isChecked(item.username)"
- @click="unselect(item.username)"
- >
- <gl-avatar-link>
- <gl-avatar-labeled
- :size="32"
- :label="item.name"
- :sub-label="item.username"
- :src="item.avatarUrl || item.avatar"
- />
- </gl-avatar-link>
- </gl-dropdown-item>
- <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
- <gl-dropdown-item
- v-for="unselectedUser in unSelectedFiltered"
- :key="unselectedUser.id"
- :data-testid="`item_${unselectedUser.name}`"
- @click="selectAssignee(unselectedUser)"
- >
- <gl-avatar-link>
- <gl-avatar-labeled
- :size="32"
- :label="unselectedUser.name"
- :sub-label="unselectedUser.username"
- :src="unselectedUser.avatarUrl || unselectedUser.avatar"
- />
- </gl-avatar-link>
- </gl-dropdown-item>
+ <gl-loading-icon v-if="$apollo.queries.participants.loading" size="lg" />
+ <template v-else>
+ <gl-dropdown-item
+ :is-checked="selectedIsEmpty"
+ data-testid="unassign"
+ class="mt-2"
+ @click="selectAssignee()"
+ >{{ $options.i18n.unassigned }}</gl-dropdown-item
+ >
+ <gl-dropdown-divider data-testid="unassign-divider" />
+ <gl-dropdown-item
+ v-for="item in selected"
+ :key="item.id"
+ :is-checked="isChecked(item.username)"
+ @click="unselect(item.username)"
+ >
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="item.name"
+ :sub-label="item.username"
+ :src="item.avatarUrl || item.avatar"
+ />
+ </gl-avatar-link>
+ </gl-dropdown-item>
+ <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
+ <gl-dropdown-item
+ v-for="unselectedUser in unSelectedFiltered"
+ :key="unselectedUser.id"
+ :data-testid="`item_${unselectedUser.name}`"
+ @click="selectAssignee(unselectedUser)"
+ >
+ <gl-avatar-link>
+ <gl-avatar-labeled
+ :size="32"
+ :label="unselectedUser.name"
+ :sub-label="unselectedUser.username"
+ :src="unselectedUser.avatarUrl || unselectedUser.avatar"
+ />
+ </gl-avatar-link>
+ </gl-dropdown-item>
+ </template>
</template>
</multi-select-dropdown>
</template>
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index cb93340bcf8..753e6941c43 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -2,15 +2,12 @@
// This component is being replaced in favor of './board_column_new.vue' for GraphQL boards
import Sortable from 'sortablejs';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue';
-import EmptyComponent from '~/vue_shared/components/empty_component';
import BoardList from './board_list.vue';
import boardsStore from '../stores/boards_store';
import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
-import { ListType } from '../constants';
export default {
components: {
- BoardPromotionState: EmptyComponent,
BoardListHeader,
BoardList,
},
@@ -42,9 +39,6 @@ export default {
};
},
computed: {
- showBoardListAndBoardInfo() {
- return this.list.type !== ListType.promotion;
- },
listIssues() {
return this.list.issues;
},
@@ -105,16 +99,7 @@ export default {
class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
>
<board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
- <board-list
- v-if="showBoardListAndBoardInfo"
- ref="board-list"
- :disabled="disabled"
- :issues="listIssues"
- :list="list"
- />
-
- <!-- Will be only available in EE -->
- <board-promotion-state v-if="list.id === 'promotion'" />
+ <board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue
index 8a59355eb83..7839f45c48b 100644
--- a/app/assets/javascripts/boards/components/board_column_new.vue
+++ b/app/assets/javascripts/boards/components/board_column_new.vue
@@ -1,13 +1,11 @@
<script>
import { mapGetters, mapActions, mapState } from 'vuex';
import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_new.vue';
-import BoardPromotionState from 'ee_else_ce/boards/components/board_promotion_state';
import BoardList from './board_list_new.vue';
-import { ListType } from '../constants';
+import { isListDraggable } from '../boards_util';
export default {
components: {
- BoardPromotionState,
BoardListHeader,
BoardList,
},
@@ -35,22 +33,17 @@ export default {
computed: {
...mapState(['filterParams']),
...mapGetters(['getIssuesByList']),
- showBoardListAndBoardInfo() {
- return this.list.type !== ListType.promotion;
- },
listIssues() {
return this.getIssuesByList(this.list.id);
},
- shouldFetchIssues() {
- return this.list.type !== ListType.blank;
+ isListDraggable() {
+ return isListDraggable(this.list);
},
},
watch: {
filterParams: {
handler() {
- if (this.shouldFetchIssues) {
- this.fetchIssuesForList({ listId: this.list.id });
- }
+ this.fetchIssuesForList({ listId: this.list.id });
},
deep: true,
immediate: true,
@@ -58,7 +51,6 @@ export default {
},
methods: {
...mapActions(['fetchIssuesForList']),
- // TODO: Reordering of lists https://gitlab.com/gitlab-org/gitlab/-/issues/280515
},
};
</script>
@@ -66,13 +58,12 @@ export default {
<template>
<div
:class="{
- 'is-draggable': !list.preset,
- 'is-expandable': list.isExpandable,
- 'is-collapsed': !list.isExpanded,
- 'board-type-assignee': list.type === 'assignee',
+ 'is-draggable': isListDraggable,
+ 'is-collapsed': list.collapsed,
+ 'board-type-assignee': list.listType === 'assignee',
}"
:data-id="list.id"
- class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
+ class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal is-expandable"
data-qa-selector="board_list"
>
<div
@@ -80,15 +71,12 @@ export default {
>
<board-list-header :can-admin-list="canAdminList" :list="list" :disabled="disabled" />
<board-list
- v-if="showBoardListAndBoardInfo"
ref="board-list"
:disabled="disabled"
:issues="listIssues"
:list="list"
+ :can-admin-list="canAdminList"
/>
-
- <!-- Will be only available in EE -->
- <board-promotion-state v-if="list.id === 'promotion'" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_configuration_options.vue b/app/assets/javascripts/boards/components/board_configuration_options.vue
index 754b00b54e0..99d1e4a2611 100644
--- a/app/assets/javascripts/boards/components/board_configuration_options.vue
+++ b/app/assets/javascripts/boards/components/board_configuration_options.vue
@@ -42,7 +42,7 @@ export default {
</script>
<template>
- <div class="append-bottom-20">
+ <div class="gl-mb-5">
<label class="label-bold gl-font-lg" for="board-new-name">
{{ __('List options') }}
</label>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 92976574efb..b366aa6fdb3 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -1,10 +1,13 @@
<script>
+import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex';
import { sortBy } from 'lodash';
import { GlAlert } from '@gitlab/ui';
-import BoardColumn from 'ee_else_ce/boards/components/board_column.vue';
+import BoardColumn from './board_column.vue';
import BoardColumnNew from './board_column_new.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import defaultSortableConfig from '~/sortable/sortable_config';
+import { sortableEnd, sortableStart } from '~/boards/mixins/sortable_default_options';
export default {
components: {
@@ -32,18 +35,51 @@ export default {
...mapState(['boardLists', 'error']),
...mapGetters(['isSwimlanesOn']),
boardListsToUse() {
- const lists =
- this.glFeatures.graphqlBoardLists || this.isSwimlanesOn ? this.boardLists : this.lists;
- return sortBy([...Object.values(lists)], 'position');
+ return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn
+ ? sortBy([...Object.values(this.boardLists)], 'position')
+ : this.lists;
+ },
+ canDragColumns() {
+ return this.glFeatures.graphqlBoardLists && this.canAdminList;
+ },
+ boardColumnWrapper() {
+ return this.canDragColumns ? Draggable : 'div';
+ },
+ draggableOptions() {
+ const options = {
+ ...defaultSortableConfig,
+ disabled: this.disabled,
+ draggable: '.is-draggable',
+ fallbackOnBody: false,
+ group: 'boards-list',
+ tag: 'div',
+ value: this.lists,
+ };
+
+ return this.canDragColumns ? options : {};
},
- },
- mounted() {
- if (this.glFeatures.graphqlBoardLists) {
- this.showPromotionList();
- }
},
methods: {
- ...mapActions(['showPromotionList']),
+ ...mapActions(['moveList']),
+ handleDragOnStart() {
+ sortableStart();
+ },
+
+ handleDragOnEnd(params) {
+ sortableEnd();
+
+ const { item, newIndex, oldIndex, to } = params;
+
+ const listId = item.dataset.id;
+ const replacedListId = to.children[newIndex].dataset.id;
+
+ this.moveList({
+ listId,
+ replacedListId,
+ newIndex,
+ adjustmentValue: newIndex < oldIndex ? 1 : -1,
+ });
+ },
},
};
</script>
@@ -53,10 +89,14 @@ export default {
<gl-alert v-if="error" variant="danger" :dismissible="false">
{{ error }}
</gl-alert>
- <div
+ <component
+ :is="boardColumnWrapper"
v-if="!isSwimlanesOn"
+ ref="list"
+ v-bind="draggableOptions"
class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap"
- data-qa-selector="boards_list"
+ @start="handleDragOnStart"
+ @end="handleDragOnEnd"
>
<board-column
v-for="list in boardListsToUse"
@@ -66,7 +106,7 @@ export default {
:list="list"
:disabled="disabled"
/>
- </div>
+ </component>
<template v-else>
<epics-swimlanes
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index e4ef3600ff9..dab934352ca 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,11 +1,14 @@
<script>
-import { __ } from '~/locale';
+import { GlModal } from '@gitlab/ui';
+import { pick } from 'lodash';
+import { __, s__ } from '~/locale';
import { deprecatedCreateFlash as Flash } from '~/flash';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { visitUrl } from '~/lib/utils/url_utility';
import boardsStore from '~/boards/stores/boards_store';
+import { fullBoardId, getBoardsPath } from '../boards_util';
import BoardConfigurationOptions from './board_configuration_options.vue';
+import createBoardMutation from '../graphql/board.mutation.graphql';
const boardDefaults = {
id: false,
@@ -19,10 +22,28 @@ const boardDefaults = {
hide_closed_list: false,
};
+const formType = {
+ new: 'new',
+ delete: 'delete',
+ edit: 'edit',
+};
+
export default {
+ i18n: {
+ [formType.new]: { title: s__('Board|Create new board'), btnText: s__('Board|Create board') },
+ [formType.delete]: { title: s__('Board|Delete board'), btnText: __('Delete') },
+ [formType.edit]: { title: s__('Board|Edit board'), btnText: __('Save changes') },
+ scopeModalTitle: s__('Board|Board scope'),
+ cancelButtonText: __('Cancel'),
+ deleteErrorMessage: s__('Board|Failed to delete board. Please try again.'),
+ saveErrorMessage: __('Unable to save your changes. Please try again.'),
+ deleteConfirmationMessage: s__('Board|Are you sure you want to delete this board?'),
+ titleFieldLabel: __('Title'),
+ titleFieldPlaceholder: s__('Board|Enter board name'),
+ },
components: {
BoardScope: () => import('ee_component/boards/components/board_scope.vue'),
- DeprecatedModal,
+ GlModal,
BoardConfigurationOptions,
},
props: {
@@ -63,36 +84,35 @@ export default {
required: false,
default: false,
},
+ currentBoard: {
+ type: Object,
+ required: true,
+ },
+ },
+ inject: {
+ endpoints: {
+ default: {},
+ },
},
data() {
return {
board: { ...boardDefaults, ...this.currentBoard },
- currentBoard: boardsStore.state.currentBoard,
currentPage: boardsStore.state.currentPage,
isLoading: false,
};
},
computed: {
isNewForm() {
- return this.currentPage === 'new';
+ return this.currentPage === formType.new;
},
isDeleteForm() {
- return this.currentPage === 'delete';
+ return this.currentPage === formType.delete;
},
isEditForm() {
- return this.currentPage === 'edit';
- },
- isVisible() {
- return this.currentPage !== '';
+ return this.currentPage === formType.edit;
},
buttonText() {
- if (this.isNewForm) {
- return __('Create board');
- }
- if (this.isDeleteForm) {
- return __('Delete');
- }
- return __('Save changes');
+ return this.$options.i18n[this.currentPage].btnText;
},
buttonKind() {
if (this.isNewForm) {
@@ -104,16 +124,11 @@ export default {
return 'info';
},
title() {
- if (this.isNewForm) {
- return __('Create new board');
- }
- if (this.isDeleteForm) {
- return __('Delete board');
- }
if (this.readonly) {
- return __('Board scope');
+ return this.$options.i18n.scopeModalTitle;
}
- return __('Edit board');
+
+ return this.$options.i18n[this.currentPage].title;
},
readonly() {
return !this.canAdminBoard;
@@ -121,6 +136,33 @@ export default {
submitDisabled() {
return this.isLoading || this.board.name.length === 0;
},
+ primaryProps() {
+ return {
+ text: this.buttonText,
+ attributes: [
+ {
+ variant: this.buttonKind,
+ disabled: this.submitDisabled,
+ loading: this.isLoading,
+ 'data-qa-selector': 'save_changes_button',
+ },
+ ],
+ };
+ },
+ cancelProps() {
+ return {
+ text: this.$options.i18n.cancelButtonText,
+ };
+ },
+ boardPayload() {
+ const { assignee, milestone, labels } = this.board;
+ return {
+ ...this.board,
+ assignee_id: assignee?.id,
+ milestone_id: milestone?.id,
+ label_ids: labels.length ? labels.map(b => b.id) : [''],
+ };
+ },
},
mounted() {
this.resetFormState();
@@ -129,6 +171,31 @@ export default {
}
},
methods: {
+ callBoardMutation(id) {
+ return this.$apollo.mutate({
+ mutation: createBoardMutation,
+ variables: {
+ ...pick(this.boardPayload, ['hideClosedList', 'hideBacklogList']),
+ id,
+ },
+ });
+ },
+ async updateBoard() {
+ const responses = await Promise.all([
+ // Remove unnecessary REST API call when https://gitlab.com/gitlab-org/gitlab/-/issues/282299#note_462996301 is resolved
+ getBoardsPath(this.endpoints.boardsEndpoint, this.boardPayload),
+ this.callBoardMutation(fullBoardId(this.boardPayload.id)),
+ ]);
+
+ return responses[0].data;
+ },
+ async createBoard() {
+ // TODO: change this to use `createBoard` mutation https://gitlab.com/gitlab-org/gitlab/-/issues/292466 is resolved
+ const boardData = await getBoardsPath(this.endpoints.boardsEndpoint, this.boardPayload);
+ this.callBoardMutation(fullBoardId(boardData.data.id));
+
+ return boardData.data || boardData;
+ },
submit() {
if (this.board.name.length === 0) return;
this.isLoading = true;
@@ -136,31 +203,21 @@ export default {
boardsStore
.deleteBoard(this.currentBoard)
.then(() => {
+ this.isLoading = false;
visitUrl(boardsStore.rootPath);
})
.catch(() => {
- Flash(__('Failed to delete board. Please try again.'));
+ Flash(this.$options.i18n.deleteErrorMessage);
this.isLoading = false;
});
} else {
- boardsStore
- .createBoard(this.board)
- .then(resp => {
- // This handles 2 use cases
- // - In create call we only get one parameter, the new board
- // - In update call, due to Promise.all, we get REST response in
- // array index 0
-
- if (Array.isArray(resp)) {
- return resp[0].data;
- }
- return resp.data ? resp.data : resp;
- })
+ const boardAction = this.boardPayload.id ? this.updateBoard : this.createBoard;
+ boardAction()
.then(data => {
visitUrl(data.board_path);
})
.catch(() => {
- Flash(__('Unable to save your changes. Please try again.'));
+ Flash(this.$options.i18n.saveErrorMessage);
this.isLoading = false;
});
}
@@ -181,53 +238,58 @@ export default {
</script>
<template>
- <deprecated-modal
- v-show="isVisible"
+ <gl-modal
+ modal-id="board-config-modal"
+ modal-class="board-config-modal"
+ content-class="gl-absolute gl-top-7"
+ visible
:hide-footer="readonly"
:title="title"
- :primary-button-label="buttonText"
- :kind="buttonKind"
- :submit-disabled="submitDisabled"
- modal-dialog-class="board-config-modal"
+ :action-primary="primaryProps"
+ :action-cancel="cancelProps"
+ @primary="submit"
@cancel="cancel"
- @submit="submit"
+ @close="cancel"
+ @hide.prevent
>
- <template #body>
- <p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p>
- <form v-else class="js-board-config-modal" @submit.prevent>
- <div v-if="!readonly" class="append-bottom-20">
- <label class="label-bold gl-font-lg" for="board-new-name">{{ __('Title') }}</label>
- <input
- id="board-new-name"
- ref="name"
- v-model="board.name"
- class="form-control"
- data-qa-selector="board_name_field"
- type="text"
- :placeholder="__('Enter board name')"
- @keyup.enter="submit"
- />
- </div>
-
- <board-configuration-options
- :is-new-form="isNewForm"
- :board="board"
- :current-board="currentBoard"
+ <p v-if="isDeleteForm" data-testid="delete-confirmation-message">
+ {{ $options.i18n.deleteConfirmationMessage }}
+ </p>
+ <form v-else class="js-board-config-modal" data-testid="board-form-wrapper" @submit.prevent>
+ <div v-if="!readonly" class="gl-mb-5" data-testid="board-form">
+ <label class="gl-font-weight-bold gl-font-lg" for="board-new-name">
+ {{ $options.i18n.titleFieldLabel }}
+ </label>
+ <input
+ id="board-new-name"
+ ref="name"
+ v-model="board.name"
+ class="form-control"
+ data-qa-selector="board_name_field"
+ type="text"
+ :placeholder="$options.i18n.titleFieldPlaceholder"
+ @keyup.enter="submit"
/>
+ </div>
- <board-scope
- v-if="scopedIssueBoardFeatureEnabled"
- :collapse-scope="isNewForm"
- :board="board"
- :can-admin-board="canAdminBoard"
- :labels-path="labelsPath"
- :labels-web-url="labelsWebUrl"
- :enable-scoped-labels="enableScopedLabels"
- :project-id="projectId"
- :group-id="groupId"
- :weights="weights"
- />
- </form>
- </template>
- </deprecated-modal>
+ <board-configuration-options
+ :is-new-form="isNewForm"
+ :board="board"
+ :current-board="currentBoard"
+ />
+
+ <board-scope
+ v-if="scopedIssueBoardFeatureEnabled"
+ :collapse-scope="isNewForm"
+ :board="board"
+ :can-admin-board="canAdminBoard"
+ :labels-path="labelsPath"
+ :labels-web-url="labelsWebUrl"
+ :enable-scoped-labels="enableScopedLabels"
+ :project-id="projectId"
+ :group-id="groupId"
+ :weights="weights"
+ />
+ </form>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 53989e2d9de..1f87b563e73 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -6,7 +6,6 @@ import boardCard from './board_card.vue';
import eventHub from '../eventhub';
import boardsStore from '../stores/boards_store';
import { sprintf, __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import {
getBoardSortableDefaultOptions,
@@ -25,7 +24,6 @@ export default {
boardNewIssue,
GlLoadingIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
disabled: {
type: Boolean,
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index d85ba2038a7..3db5c2e0830 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -72,12 +72,7 @@ export default {
return this.list?.label?.description || this.list.title || '';
},
showListHeaderButton() {
- return (
- !this.disabled &&
- this.listType !== ListType.closed &&
- this.listType !== ListType.blank &&
- this.listType !== ListType.promotion
- );
+ return !this.disabled && this.listType !== ListType.closed;
},
showMilestoneListDetails() {
return (
@@ -109,9 +104,6 @@ export default {
this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
);
},
- showBoardListAndBoardInfo() {
- return this.listType !== ListType.blank && this.listType !== ListType.promotion;
- },
uniqueKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `boards.${this.boardId}.${this.listType}.${this.list.id}`;
@@ -190,7 +182,8 @@ export default {
:title="chevronTooltip"
:icon="chevronIcon"
class="board-title-caret no-drag gl-cursor-pointer"
- variant="link"
+ category="tertiary"
+ size="small"
@click="toggleExpanded"
/>
<!-- The following is only true in EE and if it is a milestone -->
@@ -288,7 +281,6 @@ export default {
</gl-tooltip>
<div
- v-if="showBoardListAndBoardInfo"
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
:class="{
'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue
index 99347a4cd4d..44eb2aa34c2 100644
--- a/app/assets/javascripts/boards/components/board_list_header_new.vue
+++ b/app/assets/javascripts/boards/components/board_list_header_new.vue
@@ -9,15 +9,22 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
-import { n__, s__ } from '~/locale';
+import { n__, s__, __ } from '~/locale';
import AccessorUtilities from '../../lib/utils/accessor';
import IssueCount from './issue_count.vue';
import eventHub from '../eventhub';
import sidebarEventHub from '~/sidebar/event_hub';
import { inactiveId, LIST, ListType } from '../constants';
import { isScopedLabel } from '~/lib/utils/common_utils';
+import { isListDraggable } from '~/boards/boards_util';
export default {
+ i18n: {
+ newIssue: __('New issue'),
+ listSettings: __('List settings'),
+ expand: s__('Boards|Expand'),
+ collapse: s__('Boards|Collapse'),
+ },
components: {
GlButtonGroup,
GlButton,
@@ -66,57 +73,49 @@ export default {
return Boolean(this.currentUserId);
},
listType() {
- return this.list.type;
+ return this.list.listType;
},
listAssignee() {
return this.list?.assignee?.username || '';
},
listTitle() {
- return this.list?.label?.description || this.list.title || '';
+ return this.list?.label?.description || this.list?.assignee?.name || this.list.title || '';
},
showListHeaderButton() {
- return (
- !this.disabled &&
- this.listType !== ListType.closed &&
- this.listType !== ListType.blank &&
- this.listType !== ListType.promotion
- );
+ return !this.disabled && this.listType !== ListType.closed;
},
showMilestoneListDetails() {
return (
- this.list.type === ListType.milestone &&
+ this.listType === ListType.milestone &&
this.list.milestone &&
- (this.list.isExpanded || !this.isSwimlanesHeader)
+ (!this.list.collapsed || !this.isSwimlanesHeader)
);
},
showAssigneeListDetails() {
return (
- this.list.type === ListType.assignee && (this.list.isExpanded || !this.isSwimlanesHeader)
+ this.listType === ListType.assignee && (!this.list.collapsed || !this.isSwimlanesHeader)
);
},
issuesCount() {
- return this.list.issuesSize;
+ return this.list.issuesCount;
},
issuesTooltipLabel() {
return n__(`%d issue`, `%d issues`, this.issuesCount);
},
chevronTooltip() {
- return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
+ return this.list.collapsed ? this.$options.i18n.expand : this.$options.i18n.collapse;
},
chevronIcon() {
- return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
+ return this.list.collapsed ? 'chevron-down' : 'chevron-right';
},
isNewIssueShown() {
return this.listType === ListType.backlog || this.showListHeaderButton;
},
isSettingsShown() {
return (
- this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
+ this.listType !== ListType.backlog && this.showListHeaderButton && !this.list.collapsed
);
},
- showBoardListAndBoardInfo() {
- return this.listType !== ListType.blank && this.listType !== ListType.promotion;
- },
uniqueKey() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `boards.${this.boardId}.${this.listType}.${this.list.id}`;
@@ -127,6 +126,9 @@ export default {
headerStyle() {
return { borderTopColor: this.list?.label?.color };
},
+ userCanDrag() {
+ return !this.disabled && isListDraggable(this.list);
+ },
},
methods: {
...mapActions(['updateList', 'setActiveId']),
@@ -145,7 +147,7 @@ export default {
eventHub.$emit(`toggle-issue-form-${this.list.id}`);
},
toggleExpanded() {
- this.list.isExpanded = !this.list.isExpanded;
+ this.list.collapsed = !this.list.collapsed;
if (!this.isLoggedIn) {
this.addToLocalStorage();
@@ -159,11 +161,11 @@ export default {
},
addToLocalStorage() {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
+ localStorage.setItem(`${this.uniqueKey}.expanded`, !this.list.collapsed);
}
},
updateListFunction() {
- this.updateList({ listId: this.list.id, collapsed: !this.list.isExpanded });
+ this.updateList({ listId: this.list.id, collapsed: this.list.collapsed });
},
},
};
@@ -173,7 +175,7 @@ export default {
<header
:class="{
'has-border': list.label && list.label.color,
- 'gl-h-full': !list.isExpanded,
+ 'gl-h-full': list.collapsed,
'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
}"
:style="headerStyle"
@@ -183,22 +185,22 @@ export default {
>
<h3
:class="{
- 'user-can-drag': !disabled && !list.preset,
- 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
- 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
- 'gl-py-2': !list.isExpanded && isSwimlanesHeader,
- 'gl-flex-direction-column': !list.isExpanded,
+ 'user-can-drag': userCanDrag,
+ 'gl-py-3 gl-h-full': list.collapsed && !isSwimlanesHeader,
+ 'gl-border-b-0': list.collapsed || isSwimlanesHeader,
+ 'gl-py-2': list.collapsed && isSwimlanesHeader,
+ 'gl-flex-direction-column': list.collapsed,
}"
class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
>
<gl-button
- v-if="list.isExpandable"
v-gl-tooltip.hover
:aria-label="chevronTooltip"
:title="chevronTooltip"
:icon="chevronIcon"
class="board-title-caret no-drag gl-cursor-pointer"
- variant="link"
+ category="tertiary"
+ size="small"
@click="toggleExpanded"
/>
<!-- EE start -->
@@ -207,8 +209,8 @@ export default {
aria-hidden="true"
class="milestone-icon"
:class="{
- 'gl-mt-3 gl-rotate-90': !list.isExpanded,
- 'gl-mr-2': list.isExpanded,
+ 'gl-mt-3 gl-rotate-90': list.collapsed,
+ 'gl-mr-2': !list.collapsed,
}"
>
<gl-icon name="timer" />
@@ -216,17 +218,17 @@ export default {
<a
v-if="showAssigneeListDetails"
- :href="list.assignee.path"
+ :href="list.assignee.webUrl"
class="user-avatar-link js-no-trigger"
:class="{
- 'gl-mt-3 gl-rotate-90': !list.isExpanded,
+ 'gl-mt-3 gl-rotate-90': list.collapsed,
}"
>
<img
v-gl-tooltip.hover.bottom
:title="listAssignee"
:alt="list.assignee.name"
- :src="list.assignee.avatar"
+ :src="list.assignee.avatarUrl"
class="avatar s20"
height="20"
width="20"
@@ -236,9 +238,9 @@ export default {
<div
class="board-title-text"
:class="{
- 'gl-display-none': !list.isExpanded && isSwimlanesHeader,
- 'gl-flex-grow-0 gl-my-3 gl-mx-0': !list.isExpanded,
- 'gl-flex-grow-1': list.isExpanded,
+ 'gl-display-none': list.collapsed && isSwimlanesHeader,
+ 'gl-flex-grow-0 gl-my-3 gl-mx-0': list.collapsed,
+ 'gl-flex-grow-1': !list.collapsed,
}"
>
<!-- EE start -->
@@ -246,16 +248,16 @@ export default {
v-if="listType !== 'label'"
v-gl-tooltip.hover
:class="{
- 'gl-display-block': !list.isExpanded || listType === 'milestone',
+ 'gl-display-block': list.collapsed || listType === 'milestone',
}"
:title="listTitle"
class="board-title-main-text gl-text-truncate"
>
- {{ list.title }}
+ {{ listTitle }}
</span>
<span
v-if="listType === 'assignee'"
- v-show="list.isExpanded"
+ v-show="!list.collapsed"
class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
>
@{{ listAssignee }}
@@ -267,21 +269,21 @@ export default {
:background-color="list.label.color"
:description="list.label.description"
:scoped="showScopedLabels(list.label)"
- :size="!list.isExpanded ? 'sm' : ''"
+ :size="list.collapsed ? 'sm' : ''"
:title="list.label.title"
/>
</div>
<!-- EE start -->
<span
- v-if="isSwimlanesHeader && !list.isExpanded"
+ v-if="isSwimlanesHeader && list.collapsed"
ref="collapsedInfo"
aria-hidden="true"
- class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500"
+ class="board-header-collapsed-info-icon gl-cursor-pointer gl-text-gray-500"
>
<gl-icon name="information" />
</span>
- <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
+ <gl-tooltip v-if="isSwimlanesHeader && list.collapsed" :target="() => $refs.collapsedInfo">
<div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
<div v-if="list.maxIssueCount !== 0">
@@ -301,11 +303,10 @@ export default {
<!-- EE end -->
<div
- v-if="showBoardListAndBoardInfo"
class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag gl-text-gray-500"
:class="{
- 'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
- 'gl-p-0': !list.isExpanded,
+ 'gl-display-none!': list.collapsed && isSwimlanesHeader,
+ 'gl-p-0': list.collapsed,
}"
>
<span class="gl-display-inline-flex">
@@ -331,11 +332,11 @@ export default {
>
<gl-button
v-if="isNewIssueShown"
- v-show="list.isExpanded"
+ v-show="!list.collapsed"
ref="newIssueBtn"
v-gl-tooltip.hover
- :aria-label="__('New issue')"
- :title="__('New issue')"
+ :aria-label="$options.i18n.newIssue"
+ :title="$options.i18n.newIssue"
class="issue-count-badge-add-button no-drag"
icon="plus"
@click="showNewIssueForm"
@@ -345,13 +346,13 @@ export default {
v-if="isSettingsShown"
ref="settingsBtn"
v-gl-tooltip.hover
- :aria-label="__('List settings')"
+ :aria-label="$options.i18n.listSettings"
class="no-drag js-board-settings-button"
- :title="__('List settings')"
+ :title="$options.i18n.listSettings"
icon="settings"
@click="openSidebarSettings"
/>
- <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
+ <gl-tooltip :target="() => $refs.settingsBtn">{{ $options.i18n.listSettings }}</gl-tooltip>
</gl-button-group>
</h3>
</header>
diff --git a/app/assets/javascripts/boards/components/board_list_new.vue b/app/assets/javascripts/boards/components/board_list_new.vue
index 396aedcc557..92a381a8f57 100644
--- a/app/assets/javascripts/boards/components/board_list_new.vue
+++ b/app/assets/javascripts/boards/components/board_list_new.vue
@@ -1,21 +1,26 @@
<script>
+import Draggable from 'vuedraggable';
import { mapActions, mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
+import defaultSortableConfig from '~/sortable/sortable_config';
+import { sortableStart, sortableEnd } from '~/boards/mixins/sortable_default_options';
import BoardNewIssue from './board_new_issue_new.vue';
import BoardCard from './board_card.vue';
import eventHub from '../eventhub';
-import boardsStore from '../stores/boards_store';
import { sprintf, __ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'BoardList',
+ i18n: {
+ loadingIssues: __('Loading issues'),
+ loadingMoreissues: __('Loading more issues'),
+ showingAllIssues: __('Showing all issues'),
+ },
components: {
BoardCard,
BoardNewIssue,
GlLoadingIcon,
},
- mixins: [glFeatureFlagMixin()],
props: {
disabled: {
type: Boolean,
@@ -29,11 +34,15 @@ export default {
type: Array,
required: true,
},
+ canAdminList: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
scrollOffset: 250,
- filters: boardsStore.state.filters,
showCount: false,
showIssueForm: false,
};
@@ -43,11 +52,11 @@ export default {
paginatedIssueText() {
return sprintf(__('Showing %{pageSize} of %{total} issues'), {
pageSize: this.issues.length,
- total: this.list.issuesSize,
+ total: this.list.issuesCount,
});
},
issuesSizeExceedsMax() {
- return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
+ return this.list.maxIssueCount > 0 && this.list.issuesCount > this.list.maxIssueCount;
},
hasNextPage() {
return this.pageInfoByListId[this.list.id].hasNextPage;
@@ -55,15 +64,34 @@ export default {
loading() {
return this.listsFlags[this.list.id]?.isLoading;
},
+ loadingMore() {
+ return this.listsFlags[this.list.id]?.isLoadingMore;
+ },
+ listRef() {
+ // When list is draggable, the reference to the list needs to be accessed differently
+ return this.canAdminList ? this.$refs.list.$el : this.$refs.list;
+ },
+ showingAllIssues() {
+ return this.issues.length === this.list.issuesCount;
+ },
+ treeRootWrapper() {
+ return this.canAdminList ? Draggable : 'ul';
+ },
+ treeRootOptions() {
+ const options = {
+ ...defaultSortableConfig,
+ fallbackOnBody: false,
+ group: 'board-list',
+ tag: 'ul',
+ 'ghost-class': 'board-card-drag-active',
+ 'data-list-id': this.list.id,
+ value: this.issues,
+ };
+
+ return this.canAdminList ? options : {};
+ },
},
watch: {
- filters: {
- handler() {
- this.list.loadingMore = false;
- this.$refs.list.scrollTop = 0;
- },
- deep: true,
- },
issues() {
this.$nextTick(() => {
this.showCount = this.scrollHeight() > Math.ceil(this.listHeight());
@@ -76,35 +104,29 @@ export default {
},
mounted() {
// Scroll event on list to load more
- this.$refs.list.addEventListener('scroll', this.onScroll);
+ this.listRef.addEventListener('scroll', this.onScroll);
},
beforeDestroy() {
eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
- this.$refs.list.removeEventListener('scroll', this.onScroll);
+ this.listRef.removeEventListener('scroll', this.onScroll);
},
methods: {
- ...mapActions(['fetchIssuesForList']),
+ ...mapActions(['fetchIssuesForList', 'moveIssue']),
listHeight() {
- return this.$refs.list.getBoundingClientRect().height;
+ return this.listRef.getBoundingClientRect().height;
},
scrollHeight() {
- return this.$refs.list.scrollHeight;
+ return this.listRef.scrollHeight;
},
scrollTop() {
- return this.$refs.list.scrollTop + this.listHeight();
+ return this.listRef.scrollTop + this.listHeight();
},
scrollToTop() {
- this.$refs.list.scrollTop = 0;
+ this.listRef.scrollTop = 0;
},
loadNextPage() {
- const loadingDone = () => {
- this.list.loadingMore = false;
- };
- this.list.loadingMore = true;
- this.fetchIssuesForList({ listId: this.list.id, fetchNext: true })
- .then(loadingDone)
- .catch(loadingDone);
+ this.fetchIssuesForList({ listId: this.list.id, fetchNext: true });
},
toggleForm() {
this.showIssueForm = !this.showIssueForm;
@@ -112,7 +134,7 @@ export default {
onScroll() {
window.requestAnimationFrame(() => {
if (
- !this.list.loadingMore &&
+ !this.loadingMore &&
this.scrollTop() > this.scrollHeight() - this.scrollOffset &&
this.hasNextPage
) {
@@ -120,32 +142,83 @@ export default {
}
});
},
+ handleDragOnStart() {
+ sortableStart();
+ },
+ handleDragOnEnd(params) {
+ sortableEnd();
+ const { newIndex, oldIndex, from, to, item } = params;
+ const { issueId, issueIid, issuePath } = item.dataset;
+ const { children } = to;
+ let moveBeforeId;
+ let moveAfterId;
+
+ const getIssueId = el => Number(el.dataset.issueId);
+
+ // If issue is being moved within the same list
+ if (from === to) {
+ if (newIndex > oldIndex && children.length > 1) {
+ // If issue is being moved down we look for the issue that ends up before
+ moveBeforeId = getIssueId(children[newIndex]);
+ } else if (newIndex < oldIndex && children.length > 1) {
+ // If issue is being moved up we look for the issue that ends up after
+ moveAfterId = getIssueId(children[newIndex]);
+ } else {
+ // If issue remains in the same list at the same position we do nothing
+ return;
+ }
+ } else {
+ // We look for the issue that ends up before the moved issue if it exists
+ if (children[newIndex - 1]) {
+ moveBeforeId = getIssueId(children[newIndex - 1]);
+ }
+ // We look for the issue that ends up after the moved issue if it exists
+ if (children[newIndex]) {
+ moveAfterId = getIssueId(children[newIndex]);
+ }
+ }
+
+ this.moveIssue({
+ issueId,
+ issueIid,
+ issuePath,
+ fromListId: from.dataset.listId,
+ toListId: to.dataset.listId,
+ moveBeforeId,
+ moveAfterId,
+ });
+ },
},
};
</script>
<template>
<div
- v-show="list.isExpanded"
+ v-show="!list.collapsed"
class="board-list-component gl-relative gl-h-full gl-display-flex gl-flex-direction-column"
data-qa-selector="board_list_cards_area"
>
<div
v-if="loading"
class="gl-mt-4 gl-text-center"
- :aria-label="__('Loading issues')"
+ :aria-label="$options.i18n.loadingIssues"
data-testid="board_list_loading"
>
<gl-loading-icon />
</div>
- <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
- <ul
+ <board-new-issue v-if="list.listType !== 'closed' && showIssueForm" :list="list" />
+ <component
+ :is="treeRootWrapper"
v-show="!loading"
ref="list"
+ v-bind="treeRootOptions"
:data-board="list.id"
- :data-board-type="list.type"
+ :data-board-type="list.listType"
:class="{ 'bg-danger-100': issuesSizeExceedsMax }"
class="board-list gl-w-full gl-h-full gl-list-style-none gl-mb-0 gl-p-2 js-board-list"
+ data-testid="tree-root-wrapper"
+ @start="handleDragOnStart"
+ @end="handleDragOnEnd"
>
<board-card
v-for="(issue, index) in issues"
@@ -157,10 +230,10 @@ export default {
:disabled="disabled"
/>
<li v-if="showCount" class="board-list-count gl-text-center" data-issue-id="-1">
- <gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
- <span v-if="issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
+ <gl-loading-icon v-if="loadingMore" :label="$options.i18n.loadingMoreissues" />
+ <span v-if="showingAllIssues">{{ $options.i18n.showingAllIssues }}</span>
<span v-else>{{ paginatedIssueText }}</span>
</li>
- </ul>
+ </component>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_promotion_state.js b/app/assets/javascripts/boards/components/board_promotion_state.js
deleted file mode 100644
index ff8b4c56321..00000000000
--- a/app/assets/javascripts/boards/components/board_promotion_state.js
+++ /dev/null
@@ -1 +0,0 @@
-export default {};
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index 80070b25bd0..60db8fefe82 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -53,7 +53,7 @@ export default {
return this.activeList.label;
},
boardListType() {
- return this.activeList.type || null;
+ return this.activeList.type || this.activeList.listType || null;
},
listTypeTitle() {
return this.$options.labelListText;
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 0b079c78209..4f23c38d0f7 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -3,17 +3,18 @@ import { throttle } from 'lodash';
import {
GlLoadingIcon,
GlSearchBoxByType,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlModalDirective,
} from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import projectQuery from '../queries/project_boards.query.graphql';
-import groupQuery from '../queries/group_boards.query.graphql';
+import projectQuery from '../graphql/project_boards.query.graphql';
+import groupQuery from '../graphql/group_boards.query.graphql';
import boardsStore from '../stores/boards_store';
import BoardForm from './board_form.vue';
@@ -26,10 +27,13 @@ export default {
BoardForm,
GlLoadingIcon,
GlSearchBoxByType,
- GlDeprecatedDropdown,
- GlDeprecatedDropdownDivider,
- GlDeprecatedDropdownHeader,
- GlDeprecatedDropdownItem,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ },
+ directives: {
+ GlModalDirective,
},
props: {
currentBoard: {
@@ -108,7 +112,7 @@ export default {
return this.groupId ? 'group' : 'project';
},
loading() {
- return this.loadingRecentBoards && this.loadingBoards;
+ return this.loadingRecentBoards || Boolean(this.loadingBoards);
},
currentPage() {
return this.state.currentPage;
@@ -235,22 +239,17 @@ export default {
<template>
<div class="boards-switcher js-boards-selector gl-mr-3">
<span class="boards-selector-wrapper js-boards-selector-wrapper">
- <gl-deprecated-dropdown
+ <gl-dropdown
data-qa-selector="boards_dropdown"
toggle-class="dropdown-menu-toggle js-dropdown-toggle"
menu-class="flex-column dropdown-extended-height"
:text="board.name"
@show="loadBoards"
>
- <div>
- <div class="dropdown-title mb-0" @mousedown.prevent>
- {{ s__('IssueBoards|Switch board') }}
- </div>
- </div>
-
- <gl-deprecated-dropdown-header class="mt-0">
- <gl-search-box-by-type ref="searchBox" v-model="filterTerm" />
- </gl-deprecated-dropdown-header>
+ <p class="gl-new-dropdown-header-top" @mousedown.prevent>
+ {{ s__('IssueBoards|Switch board') }}
+ </p>
+ <gl-search-box-by-type ref="searchBox" v-model="filterTerm" class="m-2" />
<div
v-if="!loading"
@@ -259,49 +258,50 @@ export default {
class="dropdown-content flex-fill"
@scroll.passive="throttledSetScrollFade"
>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-show="filteredBoards.length === 0"
class="gl-pointer-events-none text-secondary"
>
{{ s__('IssueBoards|No matching boards found') }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
- <h6 v-if="showRecentSection" class="dropdown-bold-header my-0">
+ <gl-dropdown-section-header v-if="showRecentSection">
{{ __('Recent') }}
- </h6>
+ </gl-dropdown-section-header>
<template v-if="showRecentSection">
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="recentBoard in recentBoards"
:key="`recent-${recentBoard.id}`"
class="js-dropdown-item"
:href="`${boardBaseUrl}/${recentBoard.id}`"
>
{{ recentBoard.name }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</template>
- <hr v-if="showRecentSection" class="my-1" />
+ <gl-dropdown-divider v-if="showRecentSection" />
- <h6 v-if="showRecentSection" class="dropdown-bold-header my-0">
+ <gl-dropdown-section-header v-if="showRecentSection">
{{ __('All') }}
- </h6>
+ </gl-dropdown-section-header>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-for="otherBoard in filteredBoards"
:key="otherBoard.id"
class="js-dropdown-item"
:href="`${boardBaseUrl}/${otherBoard.id}`"
>
{{ otherBoard.name }}
- </gl-deprecated-dropdown-item>
- <gl-deprecated-dropdown-item v-if="hasMissingBoards" class="small unclickable">
+ </gl-dropdown-item>
+
+ <gl-dropdown-item v-if="hasMissingBoards" class="no-pointer-events">
{{
s__(
'IssueBoards|Some of your boards are hidden, activate a license to see them again.',
)
}}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</div>
<div
@@ -313,25 +313,27 @@ export default {
<gl-loading-icon v-if="loading" />
<div v-if="canAdminBoard">
- <gl-deprecated-dropdown-divider />
+ <gl-dropdown-divider />
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-if="multipleIssueBoardsAvailable"
+ v-gl-modal-directive="'board-config-modal'"
data-qa-selector="create_new_board_button"
@click.prevent="showPage('new')"
>
{{ s__('IssueBoards|Create new board') }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
- <gl-deprecated-dropdown-item
+ <gl-dropdown-item
v-if="showDelete"
+ v-gl-modal-directive="'board-config-modal'"
class="text-danger js-delete-board"
@click.prevent="showPage('delete')"
>
{{ s__('IssueBoards|Delete board') }}
- </gl-deprecated-dropdown-item>
+ </gl-dropdown-item>
</div>
- </gl-deprecated-dropdown>
+ </gl-dropdown>
<board-form
v-if="currentPage"
@@ -343,6 +345,7 @@ export default {
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights"
:enable-scoped-labels="enabledScopedLabels"
+ :current-board="currentBoard"
/>
</span>
</div>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 45ce1e51489..ddd20ff281c 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -10,6 +10,7 @@ import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
import boardsStore from '../stores/boards_store';
import { isScopedLabel } from '~/lib/utils/common_utils';
+import { ListType } from '../constants';
export default {
components: {
@@ -122,7 +123,13 @@ export default {
return true;
},
isNonListLabel(label) {
- return label.id && !(this.list.type === 'label' && this.list.title === label.title);
+ return (
+ label.id &&
+ !(
+ (this.list.type || this.list.listType) === ListType.label &&
+ this.list.title === label.title
+ )
+ );
},
filterByLabel(label) {
if (!this.updateFilters) return;
@@ -158,9 +165,13 @@ export default {
class="confidential-icon gl-mr-2"
:aria-label="__('Confidential')"
/>
- <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{
- issue.title
- }}</a>
+ <a
+ :href="issue.path || issue.webUrl || ''"
+ :title="issue.title"
+ class="js-no-trigger"
+ @mousemove.stop
+ >{{ issue.title }}</a
+ >
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels gl-mt-2 gl-display-flex gl-flex-wrap">
@@ -196,7 +207,11 @@ export default {
#{{ issue.iid }}
</span>
<span class="board-info-items gl-mt-3 gl-display-inline-block">
- <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" :closed="issue.closed" />
+ <issue-due-date
+ v-if="issue.dueDate"
+ :date="issue.dueDate"
+ :closed="issue.closed || Boolean(issue.closedAt)"
+ />
<issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
<issue-card-weight
v-if="validIssueWeight"
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 47eee5306da..d1011c24977 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -15,6 +15,7 @@ function shouldCreateListGraphQL(label) {
return store.getters.shouldUseGraphQL && !store.getters.getListByLabelId(fullLabelId(label));
}
+// eslint-disable-next-line @gitlab/no-global-event-off
$(document)
.off('created.label')
.on('created.label', (e, label, addNewList) => {
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index f90fe582566..9c90938fc52 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -7,6 +7,7 @@ import eventHub from '../eventhub';
import Api from '../../api';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import { ListType } from '../constants';
export default {
name: 'BoardProjectSelect',
@@ -53,7 +54,7 @@ export default {
this.loading = true;
const additionalAttrs = {};
- if (this.list.type && this.list.type !== 'backlog') {
+ if ((this.list.type || this.list.listType) !== ListType.backlog) {
additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
}
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 5fb7a9b210c..ce267be6d45 100644
--- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
+++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue
@@ -50,6 +50,13 @@ export default {
}
window.removeEventListener('click', this.collapseWhenOffClick);
},
+ toggle({ emitEvent = true } = {}) {
+ if (this.edit) {
+ this.collapse({ emitEvent });
+ } else {
+ this.expand();
+ }
+ },
},
};
</script>
@@ -64,18 +71,18 @@ export default {
<gl-button
v-if="canUpdate"
variant="link"
- class="gl-text-gray-900!"
+ class="gl-text-gray-900! js-sidebar-dropdown-toggle"
data-testid="edit-button"
- @click="expand()"
+ @click="toggle"
>
{{ __('Edit') }}
</gl-button>
</div>
- <div v-show="!edit" class="gl-text-gray-400" data-testid="collapsed-content">
+ <div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">
- <slot></slot>
+ <slot :edit="edit"></slot>
</div>
</div>
</template>
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 6935ead2706..904ceaed1b3 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
@@ -79,7 +79,7 @@ export default {
<span class="gl-mx-2">-</span>
<gl-button
variant="link"
- class="gl-text-gray-400!"
+ class="gl-text-gray-500!"
data-testid="reset-button"
:disabled="loading"
@click="setDueDate(null)"
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 9d537a4ef2c..6a407bd6ba6 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
@@ -92,7 +92,7 @@ export default {
@close="removeLabel(label.id)"
/>
</template>
- <template>
+ <template #default="{ edit }">
<labels-select
ref="labelsSelect"
:allow-label-edit="false"
@@ -105,6 +105,7 @@ export default {
:labels-filter-base-path="labelsFilterBasePath"
:labels-list-title="__('Select label')"
:dropdown-button-text="__('Choose labels')"
+ :is-editing="edit"
variant="embedded"
class="gl-display-block labels gl-w-full"
@updateSelectedLabels="setLabels"
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
new file mode 100644
index 00000000000..78c3f8acc62
--- /dev/null
+++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_milestone_select.vue
@@ -0,0 +1,161 @@
+<script>
+import { mapGetters, mapActions } from 'vuex';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import { fetchPolicies } from '~/lib/graphql';
+import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
+import groupMilestones from '../../graphql/group_milestones.query.graphql';
+import createFlash from '~/flash';
+import { __, s__ } from '~/locale';
+
+export default {
+ components: {
+ BoardEditableItem,
+ GlDropdown,
+ GlLoadingIcon,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ GlDropdownDivider,
+ },
+ data() {
+ return {
+ milestones: [],
+ searchTitle: '',
+ loading: false,
+ edit: false,
+ };
+ },
+ apollo: {
+ milestones: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query: groupMilestones,
+ debounce: 250,
+ skip() {
+ return !this.edit;
+ },
+ variables() {
+ return {
+ fullPath: this.groupFullPath,
+ searchTitle: this.searchTitle,
+ state: 'active',
+ includeDescendants: true,
+ };
+ },
+ update(data) {
+ const edges = data?.group?.milestones?.edges ?? [];
+ return edges.map(item => item.node);
+ },
+ error() {
+ createFlash({ message: this.$options.i18n.fetchMilestonesError });
+ },
+ },
+ },
+ computed: {
+ ...mapGetters({ issue: 'activeIssue' }),
+ hasMilestone() {
+ return this.issue.milestone !== null;
+ },
+ groupFullPath() {
+ const { referencePath = '' } = this.issue;
+ return referencePath.slice(0, referencePath.indexOf('/'));
+ },
+ projectPath() {
+ const { referencePath = '' } = this.issue;
+ return referencePath.slice(0, referencePath.indexOf('#'));
+ },
+ dropdownText() {
+ return this.issue.milestone?.title ?? this.$options.i18n.noMilestone;
+ },
+ },
+ mounted() {
+ this.$root.$on('bv::dropdown::hide', () => {
+ this.$refs.sidebarItem.collapse();
+ });
+ },
+ methods: {
+ ...mapActions(['setActiveIssueMilestone']),
+ handleOpen() {
+ this.edit = true;
+ this.$refs.dropdown.show();
+ },
+ async setMilestone(milestoneId) {
+ this.loading = true;
+ this.searchTitle = '';
+ this.$refs.sidebarItem.collapse();
+
+ try {
+ const input = { milestoneId, projectPath: this.projectPath };
+ await this.setActiveIssueMilestone(input);
+ } catch (e) {
+ createFlash({ message: this.$options.i18n.updateMilestoneError });
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+ i18n: {
+ milestone: __('Milestone'),
+ noMilestone: __('No milestone'),
+ assignMilestone: __('Assign milestone'),
+ noMilestonesFound: s__('Milestones|No milestones found'),
+ fetchMilestonesError: __('There was a problem fetching milestones.'),
+ updateMilestoneError: __('An error occurred while updating the milestone.'),
+ },
+};
+</script>
+
+<template>
+ <board-editable-item
+ ref="sidebarItem"
+ :title="$options.i18n.milestone"
+ :loading="loading"
+ @open="handleOpen()"
+ @close="edit = false"
+ >
+ <template v-if="hasMilestone" #collapsed>
+ <strong class="gl-text-gray-900">{{ issue.milestone.title }}</strong>
+ </template>
+ <template>
+ <gl-dropdown
+ ref="dropdown"
+ :text="dropdownText"
+ :header-text="$options.i18n.assignMilestone"
+ block
+ >
+ <gl-search-box-by-type ref="search" v-model.trim="searchTitle" class="gl-m-3" />
+ <gl-dropdown-item
+ data-testid="no-milestone-item"
+ :is-check-item="true"
+ :is-checked="!issue.milestone"
+ @click="setMilestone(null)"
+ >
+ {{ $options.i18n.noMilestone }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-loading-icon v-if="$apollo.loading" class="gl-py-4" />
+ <template v-else-if="milestones.length > 0">
+ <gl-dropdown-item
+ v-for="milestone in milestones"
+ :key="milestone.id"
+ :is-check-item="true"
+ :is-checked="issue.milestone && milestone.id === issue.milestone.id"
+ data-testid="milestone-item"
+ @click="setMilestone(milestone.id)"
+ >
+ {{ milestone.title }}
+ </gl-dropdown-item>
+ </template>
+ <gl-dropdown-text v-else data-testid="no-milestones-found">
+ {{ $options.i18n.noMilestonesFound }}
+ </gl-dropdown-text>
+ </gl-dropdown>
+ </template>
+ </board-editable-item>
+</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 49cb560594c..9264fac5eda 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -9,8 +9,6 @@ export const ListType = {
backlog: 'backlog',
closed: 'closed',
label: 'label',
- promotion: 'promotion',
- blank: 'blank',
};
export const inactiveId = 0;
@@ -18,11 +16,7 @@ export const inactiveId = 0;
export const ISSUABLE = 'issuable';
export const LIST = 'list';
-/* eslint-disable-next-line @gitlab/require-i18n-strings */
-export const DEFAULT_LABELS = ['to do', 'doing'];
-
export default {
BoardType,
ListType,
- DEFAULT_LABELS,
};
diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js
index 419a640d5c5..b6b34556663 100644
--- a/app/assets/javascripts/boards/ee_functions.js
+++ b/app/assets/javascripts/boards/ee_functions.js
@@ -1,5 +1,3 @@
-export const setPromotionState = () => {};
-
export const setWeightFetchingState = () => {};
export const setEpicFetchingState = () => {};
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 4fa78ecd5a4..1667dcc9f2e 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -1,7 +1,10 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
+import { transformBoardConfig } from 'ee_else_ce/boards/boards_util';
import FilteredSearchContainer from '../filtered_search/container';
import boardsStore from './stores/boards_store';
+import vuexstore from './stores';
+import { updateHistory } from '~/lib/utils/url_utility';
export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
@@ -22,18 +25,28 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
this.isHandledAsync = true;
this.cantEdit = cantEdit.filter(i => typeof i === 'string');
this.cantEditWithValue = cantEdit.filter(i => typeof i === 'object');
+
+ if (vuexstore.getters.shouldUseGraphQL && vuexstore.state.boardConfig) {
+ const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig);
+ if (boardConfigPath !== '') {
+ const filterPath = window.location.search ? `${window.location.search}&` : '?';
+ updateHistory({
+ url: `${filterPath}${transformBoardConfig(vuexstore.state.boardConfig)}`,
+ });
+ }
+ }
}
updateObject(path) {
const groupByParam = new URLSearchParams(window.location.search).get('group_by');
this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`;
- if (gon.features.boardsWithSwimlanes || gon.features.graphqlBoardLists) {
- boardsStore.updateFiltersUrl();
- boardsStore.performSearch();
- }
-
- if (this.updateUrl) {
+ if (vuexstore.getters.shouldUseGraphQL) {
+ updateHistory({
+ url: `?${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`,
+ });
+ vuexstore.dispatch('performSearch');
+ } else if (this.updateUrl) {
boardsStore.updateFiltersUrl();
}
}
diff --git a/app/assets/javascripts/boards/queries/board.fragment.graphql b/app/assets/javascripts/boards/graphql/board.fragment.graphql
index 872a4c4afbc..872a4c4afbc 100644
--- a/app/assets/javascripts/boards/queries/board.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/board.fragment.graphql
diff --git a/app/assets/javascripts/boards/queries/board.mutation.graphql b/app/assets/javascripts/boards/graphql/board.mutation.graphql
index ef2b81a7939..ef2b81a7939 100644
--- a/app/assets/javascripts/boards/queries/board.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board.mutation.graphql
diff --git a/app/assets/javascripts/boards/queries/board_labels.query.graphql b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
index 42a94419a97..42a94419a97 100644
--- a/app/assets/javascripts/boards/queries/board_labels.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_labels.query.graphql
diff --git a/app/assets/javascripts/boards/queries/board_list.fragment.graphql b/app/assets/javascripts/boards/graphql/board_list.fragment.graphql
index bbf3314377e..bbf3314377e 100644
--- a/app/assets/javascripts/boards/queries/board_list.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list.fragment.graphql
diff --git a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
index 48420b349ae..f78a21baa7f 100644
--- a/app/assets/javascripts/boards/queries/board_list_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_create.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
+#import "ee_else_ce/boards/graphql/board_list.fragment.graphql"
mutation CreateBoardList(
$boardId: BoardID!
diff --git a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql
index ef3fd36e980..ef3fd36e980 100644
--- a/app/assets/javascripts/boards/queries/board_list_destroy.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_destroy.mutation.graphql
diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql
index d85b736720b..d85b736720b 100644
--- a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_shared.fragment.graphql
diff --git a/app/assets/javascripts/boards/queries/board_list_update.mutation.graphql b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql
index b474c9acb93..b474c9acb93 100644
--- a/app/assets/javascripts/boards/queries/board_list_update.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/board_list_update.mutation.graphql
diff --git a/app/assets/javascripts/boards/queries/board_lists.query.graphql b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
index 88425e9a9c1..eb922f162f8 100644
--- a/app/assets/javascripts/boards/queries/board_lists.query.graphql
+++ b/app/assets/javascripts/boards/graphql/board_lists.query.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
+#import "ee_else_ce/boards/graphql/board_list.fragment.graphql"
query ListIssues(
$fullPath: ID!
diff --git a/app/assets/javascripts/boards/queries/group_boards.query.graphql b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
index 74c224add7d..feafd6ae10d 100644
--- a/app/assets/javascripts/boards/queries/group_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/group_boards.query.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/boards/queries/board.fragment.graphql"
+#import "ee_else_ce/boards/graphql/board.fragment.graphql"
query group_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
diff --git a/app/assets/javascripts/boards/graphql/group_milestones.query.graphql b/app/assets/javascripts/boards/graphql/group_milestones.query.graphql
new file mode 100644
index 00000000000..f2ab12ef4a7
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/group_milestones.query.graphql
@@ -0,0 +1,17 @@
+query groupMilestones(
+ $fullPath: ID!
+ $state: MilestoneStateEnum
+ $includeDescendants: Boolean
+ $searchTitle: String
+) {
+ group(fullPath: $fullPath) {
+ milestones(state: $state, includeDescendants: $includeDescendants, searchTitle: $searchTitle) {
+ edges {
+ node {
+ id
+ title
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
index 4b429f875a6..1395bef39ed 100644
--- a/app/assets/javascripts/boards/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
@@ -11,6 +11,10 @@ fragment IssueNode on Issue {
webUrl
subscribed
relativePosition
+ milestone {
+ id
+ title
+ }
assignees {
nodes {
...User
diff --git a/app/assets/javascripts/boards/queries/issue_create.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql
index 65be147be07..c1a2361a4e8 100644
--- a/app/assets/javascripts/boards/queries/issue_create.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_create.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/boards/queries/issue.fragment.graphql"
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
mutation CreateIssue($input: CreateIssueInput!) {
createIssue(input: $input) {
diff --git a/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
index ff6aa597f48..3c574fd8c87 100644
--- a/app/assets/javascripts/boards/queries/issue_move_list.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_move_list.mutation.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/boards/queries/issue.fragment.graphql"
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
mutation IssueMoveList(
$projectPath: ID!
diff --git a/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql
index bbea248cf85..bbea248cf85 100644
--- a/app/assets/javascripts/boards/queries/issue_set_due_date.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_due_date.mutation.graphql
diff --git a/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql
index 3c5f4b3e3bd..3c5f4b3e3bd 100644
--- a/app/assets/javascripts/boards/queries/issue_set_labels.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_labels.mutation.graphql
diff --git a/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql
new file mode 100644
index 00000000000..5dc78a03a06
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/issue_set_milestone.mutation.graphql
@@ -0,0 +1,12 @@
+mutation issueSetMilestone($input: UpdateIssueInput!) {
+ updateIssue(input: $input) {
+ issue {
+ milestone {
+ id
+ title
+ description
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
index 1f383245ac2..1f383245ac2 100644
--- a/app/assets/javascripts/boards/graphql/mutations/issue_set_subscription.mutation.graphql
+++ b/app/assets/javascripts/boards/graphql/issue_set_subscription.mutation.graphql
diff --git a/app/assets/javascripts/boards/queries/lists_issues.query.graphql b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
index 5dbfe4675c6..43af7d2b2f1 100644
--- a/app/assets/javascripts/boards/queries/lists_issues.query.graphql
+++ b/app/assets/javascripts/boards/graphql/lists_issues.query.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/boards/queries/issue.fragment.graphql"
+#import "ee_else_ce/boards/graphql/issue.fragment.graphql"
query ListIssues(
$fullPath: ID!
diff --git a/app/assets/javascripts/boards/queries/project_boards.query.graphql b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
index a1326bd5eff..f98d25ba671 100644
--- a/app/assets/javascripts/boards/queries/project_boards.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_boards.query.graphql
@@ -1,4 +1,4 @@
-#import "ee_else_ce/boards/queries/board.fragment.graphql"
+#import "ee_else_ce/boards/graphql/board.fragment.graphql"
query project_boards($fullPath: ID!) {
project(fullPath: $fullPath) {
diff --git a/app/assets/javascripts/boards/queries/users_search.query.graphql b/app/assets/javascripts/boards/graphql/users_search.query.graphql
index ca016495d79..ca016495d79 100644
--- a/app/assets/javascripts/boards/queries/users_search.query.graphql
+++ b/app/assets/javascripts/boards/graphql/users_search.query.graphql
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index d3e40299d8d..64a4f246735 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { mapActions, mapGetters } from 'vuex';
import 'ee_else_ce/boards/models/issue';
import 'ee_else_ce/boards/models/list';
@@ -9,7 +9,6 @@ import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
import toggleLabels from 'ee_else_ce/boards/toggle_labels';
import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
import {
- setPromotionState,
setWeightFetchingState,
setEpicFetchingState,
getMilestoneTitle,
@@ -41,7 +40,6 @@ import {
NavigationType,
convertObjectPropsToCamelCase,
parseBoolean,
- urlParamsToObject,
} from '~/lib/utils/common_utils';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
@@ -77,7 +75,6 @@ export default () => {
el: $boardApp,
components: {
BoardContent,
- Board: () => import('ee_else_ce/boards/components/board_column.vue'),
BoardSidebar,
BoardAddIssuesModal,
BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'),
@@ -114,7 +111,6 @@ export default () => {
};
},
computed: {
- ...mapState(['isShowingEpicsSwimlanes']),
...mapGetters(['shouldUseGraphQL']),
detailIssueVisible() {
return Object.keys(this.detailIssue.issue).length;
@@ -133,7 +129,17 @@ export default () => {
...endpoints,
boardType: this.parent,
disabled: this.disabled,
- showPromotion: parseBoolean($boardApp.getAttribute('data-show-promotion')),
+ boardConfig: {
+ milestoneId: parseInt($boardApp.dataset.boardMilestoneId, 10),
+ milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '',
+ iterationId: parseInt($boardApp.dataset.boardIterationId, 10),
+ iterationTitle: $boardApp.dataset.boardIterationTitle || '',
+ assigneeUsername: $boardApp.dataset.boardAssigneeUsername,
+ labels: $boardApp.dataset.labels ? JSON.parse($boardApp.dataset.labels || []) : [],
+ weight: $boardApp.dataset.boardWeight
+ ? parseInt($boardApp.dataset.boardWeight, 10)
+ : null,
+ },
});
boardsStore.setEndpoints(endpoints);
boardsStore.rootPath = this.boardsEndpoint;
@@ -142,7 +148,6 @@ export default () => {
eventHub.$on('newDetailIssue', this.updateDetailIssue);
eventHub.$on('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
- eventHub.$on('performSearch', this.performSearch);
eventHub.$on('initialBoardLoad', this.initialBoardLoad);
},
beforeDestroy() {
@@ -150,7 +155,6 @@ export default () => {
eventHub.$off('newDetailIssue', this.updateDetailIssue);
eventHub.$off('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
- eventHub.$off('performSearch', this.performSearch);
eventHub.$off('initialBoardLoad', this.initialBoardLoad);
},
mounted() {
@@ -166,22 +170,13 @@ export default () => {
}
},
methods: {
- ...mapActions([
- 'setInitialBoardData',
- 'setFilters',
- 'fetchEpicsSwimlanes',
- 'resetIssues',
- 'resetEpics',
- 'fetchLists',
- ]),
+ ...mapActions(['setInitialBoardData', 'performSearch']),
initialBoardLoad() {
boardsStore
.all()
.then(res => res.data)
.then(lists => {
lists.forEach(list => boardsStore.addList(list));
- boardsStore.addBlankState();
- setPromotionState(boardsStore);
this.loading = false;
})
.catch(() => {
@@ -191,17 +186,6 @@ export default () => {
updateTokens() {
this.filterManager.updateTokens();
},
- performSearch() {
- this.setFilters(convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)));
- if (gon.features.boardsWithSwimlanes && this.isShowingEpicsSwimlanes) {
- this.resetEpics();
- this.resetIssues();
- this.fetchEpicsSwimlanes({});
- } else if (gon.features.graphqlBoardLists && !this.isShowingEpicsSwimlanes) {
- this.fetchLists();
- this.resetIssues();
- }
- },
updateDetailIssue(newIssue, multiSelect = false) {
const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
@@ -303,7 +287,7 @@ export default () => {
const issueBoardsModal = document.getElementById('js-add-issues-btn');
- if (issueBoardsModal) {
+ if (issueBoardsModal && gon.features.addIssuesButton) {
// eslint-disable-next-line no-new
new Vue({
el: issueBoardsModal,
@@ -350,5 +334,8 @@ export default () => {
toggleEpicsSwimlanes();
}
- mountMultipleBoardsSwitcher();
+ mountMultipleBoardsSwitcher({
+ boardsEndpoint: $boardApp.dataset.boardsEndpoint,
+ recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
+ });
};
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index 51bb72b7657..df65ebb7526 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -10,7 +10,7 @@ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
-export default () => {
+export default (endpoints = {}) => {
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
return new Vue({
el: boardsSwitcherElement,
@@ -35,6 +35,9 @@ export default () => {
return { boardsSelectorProps };
},
+ provide: {
+ endpoints,
+ },
render(createElement) {
return createElement(BoardsSelector, {
props: this.boardsSelectorProps,
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
index dd950a45076..59b97eba9fe 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -1,9 +1,10 @@
import { pick } from 'lodash';
-import boardListsQuery from 'ee_else_ce/boards/queries/board_lists.query.graphql';
+import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { BoardType, ListType, inactiveId, DEFAULT_LABELS } from '~/boards/constants';
+import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
+import { BoardType, ListType, inactiveId } from '~/boards/constants';
import * as types from './mutation_types';
import {
formatBoardLists,
@@ -12,19 +13,20 @@ import {
formatListsPageInfo,
formatIssue,
} from '../boards_util';
-import boardStore from '~/boards/stores/boards_store';
-
-import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
-import listsIssuesQuery from '../queries/lists_issues.query.graphql';
-import boardLabelsQuery from '../queries/board_labels.query.graphql';
-import createBoardListMutation from '../queries/board_list_create.mutation.graphql';
-import updateBoardListMutation from '../queries/board_list_update.mutation.graphql';
-import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql';
-import destroyBoardListMutation from '../queries/board_list_destroy.mutation.graphql';
-import issueCreateMutation from '../queries/issue_create.mutation.graphql';
-import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
-import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
-import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
+import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
+import boardLabelsQuery from '../graphql/board_labels.query.graphql';
+import createBoardListMutation from '../graphql/board_list_create.mutation.graphql';
+import updateBoardListMutation from '../graphql/board_list_update.mutation.graphql';
+import issueMoveListMutation from '../graphql/issue_move_list.mutation.graphql';
+import destroyBoardListMutation from '../graphql/board_list_destroy.mutation.graphql';
+import issueCreateMutation from '../graphql/issue_create.mutation.graphql';
+import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql';
+import issueSetDueDateMutation from '../graphql/issue_set_due_date.mutation.graphql';
+import issueSetSubscriptionMutation from '../graphql/issue_set_subscription.mutation.graphql';
+import issueSetMilestoneMutation from '../graphql/issue_set_milestone.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
@@ -63,6 +65,18 @@ export default {
commit(types.SET_FILTERS, filterParams);
},
+ performSearch({ dispatch }) {
+ dispatch(
+ 'setFilters',
+ convertObjectPropsToCamelCase(urlParamsToObject(window.location.search)),
+ );
+
+ if (gon.features.graphqlBoardLists) {
+ dispatch('fetchLists');
+ dispatch('resetIssues');
+ }
+ },
+
fetchLists: ({ commit, state, dispatch }) => {
const { endpoints, boardType, filterParams } = state;
const { fullPath, boardId } = endpoints;
@@ -87,7 +101,6 @@ export default {
if (!lists.nodes.find(l => l.listType === ListType.backlog) && !hideBacklogList) {
dispatch('createList', { backlog: true });
}
- dispatch('generateDefaultLists');
})
.catch(() => commit(types.RECEIVE_BOARD_LISTS_FAILURE));
},
@@ -118,15 +131,9 @@ export default {
},
addList: ({ commit }, list) => {
- // Temporarily using positioning logic from boardStore
- commit(
- types.RECEIVE_ADD_LIST_SUCCESS,
- boardStore.updateListPosition({ ...list, doNotFetchIssues: true }),
- );
+ commit(types.RECEIVE_ADD_LIST_SUCCESS, list);
},
- showPromotionList: () => {},
-
fetchLabels: ({ state, commit }, searchTerm) => {
const { endpoints, boardType } = state;
const { fullPath } = endpoints;
@@ -150,35 +157,14 @@ export default {
.catch(() => commit(types.RECEIVE_LABELS_FAILURE));
},
- generateDefaultLists: async ({ state, commit, dispatch }) => {
- if (state.disabled) {
- return;
- }
- if (
- Object.entries(state.boardLists).find(
- ([, list]) => list.type !== ListType.backlog && list.type !== ListType.closed,
- )
- ) {
- return;
- }
-
- const fetchLabelsAndCreateList = label => {
- return dispatch('fetchLabels', label)
- .then(res => {
- if (res.length > 0) {
- dispatch('createList', { labelId: res[0].id });
- }
- })
- .catch(() => commit(types.GENERATE_DEFAULT_LISTS_FAILURE));
- };
-
- await Promise.all(DEFAULT_LABELS.map(fetchLabelsAndCreateList));
- },
-
moveList: (
{ state, commit, dispatch },
{ listId, replacedListId, newIndex, adjustmentValue },
) => {
+ if (listId === replacedListId) {
+ return;
+ }
+
const { boardLists } = state;
const backupList = { ...boardLists };
const movedList = boardLists[listId];
@@ -315,9 +301,11 @@ export default {
},
setAssignees: ({ commit, getters }, assigneeUsernames) => {
+ commit(types.SET_ASSIGNEE_LOADING, true);
+
return gqlClient
.mutate({
- mutation: updateAssignees,
+ mutation: updateAssigneesMutation,
variables: {
iid: getters.activeIssue.iid,
projectPath: getters.activeIssue.referencePath.split('#')[0],
@@ -325,14 +313,48 @@ export default {
},
})
.then(({ data }) => {
+ const { nodes } = data.issueSetAssignees?.issue?.assignees || [];
+
commit('UPDATE_ISSUE_BY_ID', {
issueId: getters.activeIssue.id,
prop: 'assignees',
- value: data.issueSetAssignees.issue.assignees.nodes,
+ value: nodes,
});
+
+ return nodes;
+ })
+ .catch(() => {
+ createFlash({ message: __('An error occurred while updating assignees.') });
+ })
+ .finally(() => {
+ commit(types.SET_ASSIGNEE_LOADING, false);
});
},
+ setActiveIssueMilestone: async ({ commit, getters }, input) => {
+ const { activeIssue } = getters;
+ const { data } = await gqlClient.mutate({
+ mutation: issueSetMilestoneMutation,
+ variables: {
+ input: {
+ iid: String(activeIssue.iid),
+ milestoneId: getIdFromGraphQLId(input.milestoneId),
+ projectPath: input.projectPath,
+ },
+ },
+ });
+
+ if (data.updateIssue.errors?.length > 0) {
+ throw new Error(data.updateIssue.errors);
+ }
+
+ commit(types.UPDATE_ISSUE_BY_ID, {
+ issueId: activeIssue.id,
+ prop: 'milestone',
+ value: data.updateIssue.issue.milestone,
+ });
+ },
+
createNewIssue: ({ commit, state }, issueInput) => {
const input = issueInput;
const { boardType, endpoints } = state;
@@ -378,7 +400,7 @@ export default {
setActiveIssueLabels: async ({ commit, getters }, input) => {
const { activeIssue } = getters;
const { data } = await gqlClient.mutate({
- mutation: issueSetLabels,
+ mutation: issueSetLabelsMutation,
variables: {
input: {
iid: String(activeIssue.iid),
@@ -403,7 +425,7 @@ export default {
setActiveIssueDueDate: async ({ commit, getters }, input) => {
const { activeIssue } = getters;
const { data } = await gqlClient.mutate({
- mutation: issueSetDueDate,
+ mutation: issueSetDueDateMutation,
variables: {
input: {
iid: String(activeIssue.iid),
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 337b2897fe9..36702b6ca5f 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -1,9 +1,8 @@
/* eslint-disable no-shadow, no-param-reassign,consistent-return */
/* global List */
/* global ListIssue */
-import { sortBy, pick } from 'lodash';
+import { sortBy } from 'lodash';
import Vue from 'vue';
-import Cookies from 'js-cookie';
import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
import {
urlParamsToObject,
@@ -22,8 +21,6 @@ import ListLabel from '../models/label';
import ListAssignee from '../models/assignee';
import ListMilestone from '../models/milestone';
-import createBoardMutation from '../queries/board.mutation.graphql';
-
const PER_PAGE = 20;
export const gqlClient = createDefaultClient();
@@ -125,20 +122,6 @@ const boardsStore = {
.querySelector(`.js-board-list-${getIdFromGraphQLId(listId)}`)
?.classList.remove('is-active');
},
- shouldAddBlankState() {
- // Decide whether to add the blank state
- return !this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'closed')[0];
- },
- addBlankState() {
- if (!this.shouldAddBlankState() || this.welcomeIsHidden()) return;
-
- this.generateDefaultLists()
- .then(res => res.data)
- .then(data => Promise.all(data.map(list => this.addList(list))))
- .catch(() => {
- this.removeList(undefined, 'label');
- });
- },
findIssueLabel(issue, findLabel) {
return issue.labels.find(label => label.id === findLabel.id);
@@ -202,9 +185,6 @@ const boardsStore = {
return list.issues.find(issue => issue.id === id);
},
- welcomeIsHidden() {
- return parseBoolean(Cookies.get('issue_board_welcome_hidden'));
- },
removeList(id, type = 'blank') {
const list = this.findList('id', id, type);
@@ -302,11 +282,7 @@ const boardsStore = {
onNewListIssueResponse(list, issue, data) {
issue.refreshData(data);
- if (
- !gon.features.boardsWithSwimlanes &&
- !gon.features.graphqlBoardLists &&
- list.issues.length > 1
- ) {
+ if (list.issues.length > 1) {
const moveBeforeId = list.issues[1].id;
this.moveIssue(issue.id, null, null, null, moveBeforeId);
}
@@ -516,10 +492,6 @@ const boardsStore = {
eventHub.$emit('updateTokens');
},
- performSearch() {
- eventHub.$emit('performSearch');
- },
-
setListDetail(newList) {
this.detail.list = newList;
},
@@ -566,10 +538,6 @@ const boardsStore = {
return axios.get(this.state.endpoints.listsEndpoint);
},
- generateDefaultLists() {
- return axios.post(this.state.endpoints.listsEndpointGenerate, {});
- },
-
createList(entityId, entityType) {
const list = {
[entityType]: entityId,
@@ -785,52 +753,6 @@ const boardsStore = {
return axios.get(this.state.endpoints.recentBoardsEndpoint);
},
- createBoard(board) {
- const boardPayload = { ...board };
- boardPayload.label_ids = (board.labels || []).map(b => b.id);
-
- if (boardPayload.label_ids.length === 0) {
- boardPayload.label_ids = [''];
- }
-
- if (boardPayload.assignee) {
- boardPayload.assignee_id = boardPayload.assignee.id;
- }
-
- if (boardPayload.milestone) {
- boardPayload.milestone_id = boardPayload.milestone.id;
- }
-
- if (boardPayload.id) {
- const input = {
- ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']),
- id: this.generateBoardGid(boardPayload.id),
- };
-
- return Promise.all([
- axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload }),
- gqlClient.mutate({
- mutation: createBoardMutation,
- variables: input,
- }),
- ]);
- }
-
- return axios
- .post(this.generateBoardsPath(), { board: boardPayload })
- .then(resp => resp.data)
- .then(data => {
- gqlClient.mutate({
- mutation: createBoardMutation,
- variables: {
- ...pick(boardPayload, ['hideClosedList', 'hideBacklogList']),
- id: this.generateBoardGid(data.id),
- },
- });
- return data;
- });
- },
-
deleteBoard({ id }) {
return axios.delete(this.generateBoardsPath(id));
},
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index cd28b4a0ff7..ca6887b6f45 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -2,15 +2,8 @@ import { find } from 'lodash';
import { inactiveId } from '../constants';
export default {
- labelToggleState: state => (state.isShowingLabels ? 'on' : 'off'),
isSidebarOpen: state => state.activeId !== inactiveId,
- isSwimlanesOn: state => {
- if (!gon?.features?.boardsWithSwimlanes && !gon?.features?.swimlanes) {
- return false;
- }
-
- return state.isShowingEpicsSwimlanes;
- },
+ isSwimlanesOn: () => false,
getIssueById: state => id => {
return state.issues[id] || {};
},
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 3a57cb9b5e1..2b2c2bee51c 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -34,4 +34,5 @@ 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 SET_ASSIGNEE_LOADING = 'SET_ASSIGNEE_LOADING';
export const RESET_ISSUES = 'RESET_ISSUES';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index bb083158c8f..8c4e514710f 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -13,7 +13,7 @@ const notImplemented = () => {
export const removeIssueFromList = ({ state, listId, issueId }) => {
Vue.set(state.issuesByListId, listId, pull(state.issuesByListId[listId], issueId));
const list = state.boardLists[listId];
- Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize - 1 });
+ Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount - 1 });
};
export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfterId, atIndex }) => {
@@ -27,16 +27,16 @@ export const addIssueToList = ({ state, listId, issueId, moveBeforeId, moveAfter
listIssues.splice(newIndex, 0, issueId);
Vue.set(state.issuesByListId, listId, listIssues);
const list = state.boardLists[listId];
- Vue.set(state.boardLists, listId, { ...list, issuesSize: list.issuesSize + 1 });
+ Vue.set(state.boardLists, listId, { ...list, issuesCount: list.issuesCount + 1 });
};
export default {
[mutationTypes.SET_INITIAL_BOARD_DATA](state, data) {
- const { boardType, disabled, showPromotion, ...endpoints } = data;
+ const { boardType, disabled, boardConfig, ...endpoints } = data;
state.endpoints = endpoints;
state.boardType = boardType;
state.disabled = disabled;
- state.showPromotion = showPromotion;
+ state.boardConfig = boardConfig;
},
[mutationTypes.RECEIVE_BOARD_LISTS_SUCCESS]: (state, lists) => {
@@ -143,6 +143,10 @@ export default {
Vue.set(state.issues[issueId], prop, value);
},
+ [mutationTypes.SET_ASSIGNEE_LOADING](state, isLoading) {
+ state.isSettingAssignees = isLoading;
+ },
+
[mutationTypes.REQUEST_ADD_ISSUE]: () => {
notImplemented();
},
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index b91c09f8051..573e98e56e0 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -4,16 +4,17 @@ export default () => ({
endpoints: {},
boardType: null,
disabled: false,
- showPromotion: false,
isShowingLabels: true,
activeId: inactiveId,
sidebarType: '',
boardLists: {},
listsFlags: {},
issuesByListId: {},
+ isSettingAssignees: false,
pageInfoByListId: {},
issues: {},
filterParams: {},
+ boardConfig: {},
error: undefined,
// TODO: remove after ce/ee split of board_content.vue
isShowingEpicsSwimlanes: false,
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint.vue b/app/assets/javascripts/ci_lint/components/ci_lint.vue
index def45026b35..731ed2ddd01 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint.vue
+++ b/app/assets/javascripts/ci_lint/components/ci_lint.vue
@@ -1,8 +1,8 @@
<script>
import { GlButton, GlFormCheckbox, GlIcon, GlLink, GlAlert } from '@gitlab/ui';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
-import CiLintResults from './ci_lint_results.vue';
-import lintCIMutation from '../graphql/mutations/lint_ci.mutation.graphql';
+import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue';
+import lintCiMutation from '~/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql';
export default {
components: {
@@ -56,7 +56,7 @@ export default {
lintCI: { valid, errors, warnings, jobs },
},
} = await this.$apollo.mutate({
- mutation: lintCIMutation,
+ mutation: lintCiMutation,
variables: { endpoint: this.endpoint, content: this.content, dry: this.dryRun },
});
@@ -119,6 +119,7 @@ export default {
<ci-lint-results
v-if="showingResults"
+ class="col-sm-12 gl-mt-5"
:valid="valid"
:jobs="jobs"
:errors="errors"
diff --git a/app/assets/javascripts/ci_lint/graphql/resolvers.js b/app/assets/javascripts/ci_lint/graphql/resolvers.js
deleted file mode 100644
index 126b4c664b2..00000000000
--- a/app/assets/javascripts/ci_lint/graphql/resolvers.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-const resolvers = {
- Mutation: {
- lintCI: (_, { endpoint, content, dry_run }) => {
- return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({
- valid: data.valid,
- errors: data.errors,
- warnings: data.warnings,
- jobs: data.jobs.map(job => {
- const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null;
-
- return {
- name: job.name,
- stage: job.stage,
- beforeScript: job.before_script,
- script: job.script,
- afterScript: job.after_script,
- tagList: job.tag_list,
- environment: job.environment,
- when: job.when,
- allowFailure: job.allow_failure,
- only,
- except: job.except,
- __typename: 'CiLintJob',
- };
- }),
- __typename: 'CiLintContent',
- }));
- },
- },
-};
-
-export default resolvers;
diff --git a/app/assets/javascripts/ci_lint/index.js b/app/assets/javascripts/ci_lint/index.js
index e4cda4cb369..274aab45deb 100644
--- a/app/assets/javascripts/ci_lint/index.js
+++ b/app/assets/javascripts/ci_lint/index.js
@@ -1,8 +1,9 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
+import { resolvers } from '~/pipeline_editor/graphql/resolvers';
+
import CiLint from './components/ci_lint.vue';
-import resolvers from './graphql/resolvers';
Vue.use(VueApollo);
diff --git a/app/assets/javascripts/clone_panel.js b/app/assets/javascripts/clone_panel.js
new file mode 100644
index 00000000000..362e6c5c5ce
--- /dev/null
+++ b/app/assets/javascripts/clone_panel.js
@@ -0,0 +1,42 @@
+import $ from 'jquery';
+
+export default function initClonePanel() {
+ const $cloneOptions = $('ul.clone-options-dropdown');
+ if ($cloneOptions.length) {
+ const $cloneUrlField = $('#clone_url');
+ const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label');
+ const mobileCloneField = document.querySelector(
+ '.js-mobile-git-clone .js-clone-dropdown-label',
+ );
+
+ const selectedCloneOption = $cloneBtnLabel.text().trim();
+ if (selectedCloneOption.length > 0) {
+ $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active');
+ }
+
+ $('a', $cloneOptions).on('click', e => {
+ e.preventDefault();
+ const $this = $(e.currentTarget);
+ const url = $this.attr('href');
+ const cloneType = $this.data('cloneType');
+
+ $('.is-active', $cloneOptions).removeClass('is-active');
+ $(`a[data-clone-type="${cloneType}"]`).each(function switchProtocol() {
+ const $el = $(this);
+ const activeText = $el.find('.dropdown-menu-inner-title').text();
+ const $container = $el.closest('.js-git-clone-holder, .js-mobile-git-clone');
+ const $label = $container.find('.js-clone-dropdown-label');
+
+ $el.toggleClass('is-active');
+ $label.text(activeText);
+ });
+
+ if (mobileCloneField) {
+ mobileCloneField.dataset.clipboardText = url;
+ } else {
+ $cloneUrlField.val(url);
+ }
+ $('.js-git-empty .js-clone').text(url);
+ });
+ }
+}
diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js
deleted file mode 100644
index 9bbbe07e7a1..00000000000
--- a/app/assets/javascripts/close_reopen_report_toggle.js
+++ /dev/null
@@ -1,92 +0,0 @@
-import DropLab from './droplab/drop_lab';
-import ISetter from './droplab/plugins/input_setter';
-
-// Todo: Remove this when fixing issue in input_setter plugin
-const InputSetter = { ...ISetter };
-
-class CloseReopenReportToggle {
- constructor(opts = {}) {
- this.dropdownTrigger = opts.dropdownTrigger;
- this.dropdownList = opts.dropdownList;
- this.button = opts.button;
- }
-
- initDroplab() {
- this.reopenItem = this.dropdownList.querySelector('.reopen-item');
- this.closeItem = this.dropdownList.querySelector('.close-item');
-
- this.droplab = new DropLab();
-
- const config = this.setConfig();
-
- this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config);
- }
-
- updateButton(isClosed) {
- this.toggleButtonType(isClosed);
-
- this.button.blur();
- }
-
- toggleButtonType(isClosed) {
- const [showItem, hideItem] = this.getButtonTypes(isClosed);
-
- showItem.classList.remove('hidden');
- showItem.classList.add('droplab-item-selected');
-
- hideItem.classList.add('hidden');
- hideItem.classList.remove('droplab-item-selected');
-
- showItem.click();
- }
-
- getButtonTypes(isClosed) {
- return isClosed ? [this.reopenItem, this.closeItem] : [this.closeItem, this.reopenItem];
- }
-
- setDisable(shouldDisable = true) {
- if (shouldDisable) {
- this.button.setAttribute('disabled', 'true');
- this.dropdownTrigger.setAttribute('disabled', 'true');
- } else {
- this.button.removeAttribute('disabled');
- this.dropdownTrigger.removeAttribute('disabled');
- }
- }
-
- setConfig() {
- const config = {
- InputSetter: [
- {
- input: this.button,
- valueAttribute: 'data-text',
- inputAttribute: 'data-value',
- },
- {
- input: this.button,
- valueAttribute: 'data-text',
- inputAttribute: 'title',
- },
- {
- input: this.button,
- valueAttribute: 'data-button-class',
- inputAttribute: 'class',
- },
- {
- input: this.dropdownTrigger,
- valueAttribute: 'data-toggle-class',
- inputAttribute: 'class',
- },
- {
- input: this.button,
- valueAttribute: 'data-url',
- inputAttribute: 'data-endpoint',
- },
- ],
- };
-
- return config;
- }
-}
-
-export default CloseReopenReportToggle;
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index a75646db162..a533a1a78e8 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -52,6 +52,7 @@ export default class Clusters {
clusterStatus,
clusterStatusReason,
helpPath,
+ helmHelpPath,
ingressHelpPath,
ingressDnsHelpPath,
ingressModSecurityHelpPath,
@@ -68,8 +69,9 @@ export default class Clusters {
this.clusterBannerDismissedKey = `cluster_${this.clusterId}_banner_dismissed`;
this.store = new ClustersStore();
- this.store.setHelpPaths(
+ this.store.setHelpPaths({
helpPath,
+ helmHelpPath,
ingressHelpPath,
ingressDnsHelpPath,
ingressModSecurityHelpPath,
@@ -78,7 +80,7 @@ export default class Clusters {
deployBoardsHelpPath,
cloudRunHelpPath,
ciliumHelpPath,
- );
+ });
this.store.setManagePrometheusPath(managePrometheusPath);
this.store.updateStatus(clusterStatus);
this.store.updateStatusReason(clusterStatusReason);
@@ -162,6 +164,7 @@ export default class Clusters {
type,
applications: this.state.applications,
helpPath: this.state.helpPath,
+ helmHelpPath: this.state.helmHelpPath,
ingressHelpPath: this.state.ingressHelpPath,
managePrometheusPath: this.state.managePrometheusPath,
ingressDnsHelpPath: this.state.ingressDnsHelpPath,
@@ -262,13 +265,21 @@ export default class Clusters {
removeListeners() {
eventHub.$off('installApplication', this.installApplication);
eventHub.$off('updateApplication', this.updateApplication);
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('saveKnativeDomain');
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('setKnativeDomain');
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('setCrossplaneProviderStack');
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('uninstallApplication');
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('setIngressModSecurityEnabled');
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('setIngressModSecurityMode');
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('resetIngressModSecurityChanges');
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('setFluentdSettings');
}
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 271d862afab..fdffaa24d03 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -1,6 +1,7 @@
<script>
-import { GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlLoadingIcon, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import gitlabLogo from 'images/cluster_app_logos/gitlab.png';
+import helmLogo from 'images/cluster_app_logos/helm.png';
import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png';
import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png';
import certManagerLogo from 'images/cluster_app_logos/cert_manager.png';
@@ -29,6 +30,7 @@ export default {
CrossplaneProviderStack,
IngressModsecuritySettings,
FluentdOutputSettings,
+ GlAlert,
},
props: {
type: {
@@ -46,6 +48,11 @@ export default {
required: false,
default: '',
},
+ helmHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
ingressHelpPath: {
type: String,
required: false,
@@ -150,6 +157,7 @@ export default {
},
logos: {
gitlabLogo,
+ helmLogo,
jupyterhubLogo,
kubernetesLogo,
certManagerLogo,
@@ -173,6 +181,35 @@ export default {
<div class="cluster-application-list gl-mt-3">
<application-row
+ v-if="applications.helm.installed || applications.helm.uninstalling"
+ id="helm"
+ :logo-url="$options.logos.helmLogo"
+ :title="applications.helm.title"
+ :status="applications.helm.status"
+ :status-reason="applications.helm.statusReason"
+ :request-status="applications.helm.requestStatus"
+ :request-reason="applications.helm.requestReason"
+ :installed="applications.helm.installed"
+ :install-failed="applications.helm.installFailed"
+ :uninstallable="applications.helm.uninstallable"
+ :uninstall-successful="applications.helm.uninstallSuccessful"
+ :uninstall-failed="applications.helm.uninstallFailed"
+ title-link="https://v2.helm.sh/"
+ >
+ <template #description>
+ <p>
+ {{
+ s__(`ClusterIntegration|Can be safely removed. Prior to GitLab
+ 13.2, GitLab used a remote Tiller server to manage the
+ applications. GitLab no longer uses this server.
+ Uninstalling this server will not affect your other
+ applications. This row will disappear afterwards.`)
+ }}
+ <gl-link :href="helmHelpPath">{{ __('More information') }}</gl-link>
+ </p>
+ </template>
+ </application-row>
+ <application-row
:id="ingressId"
:logo-url="$options.logos.kubernetesLogo"
:title="applications.ingress.title"
@@ -257,8 +294,8 @@ export default {
</p>
</template>
<template v-else>
- <div class="bs-callout bs-callout-info">
- <strong data-testid="ingressCostWarning">
+ <gl-alert variant="info" :dismissible="false">
+ <span data-testid="ingressCostWarning">
<gl-sprintf
:message="
s__(
@@ -272,8 +309,8 @@ export default {
}}</gl-link>
</template>
</gl-sprintf>
- </strong>
- </div>
+ </span>
+ </gl-alert>
</template>
</template>
</application-row>
@@ -536,13 +573,13 @@ export default {
title-link="https://github.com/knative/docs"
>
<template #description>
- <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info">
+ <gl-alert v-if="!rbac" variant="info" class="rbac-notice gl-my-3" :dismissible="false">
{{
s__(`ClusterIntegration|You must have an RBAC-enabled cluster
to install Knative.`)
}}
<gl-link :href="helpPath" target="_blank">{{ __('More information') }}</gl-link>
- </p>
+ </gl-alert>
<p>
{{
s__(`ClusterIntegration|Knative extends Kubernetes to provide
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index cb415d902e8..d80bd6f5b42 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -7,6 +7,7 @@ import {
GlSearchBoxByType,
GlSprintf,
GlButton,
+ GlAlert,
} from '@gitlab/ui';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import { __, s__ } from '~/locale';
@@ -25,6 +26,7 @@ export default {
GlDropdownItem,
GlSearchBoxByType,
GlSprintf,
+ GlAlert,
},
props: {
knative: {
@@ -106,12 +108,13 @@ export default {
<template>
<div class="row">
- <div
+ <gl-alert
v-if="knative.updateFailed"
- class="bs-callout bs-callout-danger cluster-application-banner col-12 mt-2 mb-2 js-cluster-knative-domain-name-failure-message"
+ class="gl-mb-5 col-12 js-cluster-knative-domain-name-failure-message"
+ variant="danger"
>
{{ s__('ClusterIntegration|Something went wrong while updating Knative domain name.') }}
- </div>
+ </gl-alert>
<div
:class="{ 'col-md-6': knativeInstalled, 'col-12': !knativeInstalled }"
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
index 477dd13db4f..2a197e40b60 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -16,7 +16,7 @@ import {
const CUSTOM_APP_WARNING_TEXT = {
[HELM]: sprintf(
s__(
- 'ClusterIntegration|The associated Tiller pod, the %{gitlabManagedAppsNamespace} namespace, and all of its resources will be deleted and cannot be restored.',
+ 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored. Your other applications will remain unaffected.',
),
{
gitlabManagedAppsNamespace: '<code>gitlab-managed-apps</code>',
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
index 683b0e18534..1dd815ae44d 100644
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -193,6 +193,12 @@ const applicationStateMachine = {
uninstallSuccessful: true,
},
},
+ [NOT_INSTALLABLE]: {
+ target: NOT_INSTALLABLE,
+ effects: {
+ uninstallSuccessful: true,
+ },
+ },
[UNINSTALL_ERRORED]: {
target: INSTALLED,
effects: {
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index 53868b7c02d..88505eac3a9 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -36,6 +36,7 @@ export default class ClusterStore {
constructor() {
this.state = {
helpPath: null,
+ helmHelpPath: null,
ingressHelpPath: null,
environmentsHelpPath: null,
clustersHelpPath: null,
@@ -49,7 +50,7 @@ export default class ClusterStore {
applications: {
helm: {
...applicationInitialState,
- title: s__('ClusterIntegration|Helm Tiller'),
+ title: s__('ClusterIntegration|Legacy Helm Tiller server'),
},
ingress: {
...applicationInitialState,
@@ -126,26 +127,10 @@ export default class ClusterStore {
};
}
- setHelpPaths(
- helpPath,
- ingressHelpPath,
- ingressDnsHelpPath,
- ingressModSecurityHelpPath,
- environmentsHelpPath,
- clustersHelpPath,
- deployBoardsHelpPath,
- cloudRunHelpPath,
- ciliumHelpPath,
- ) {
- this.state.helpPath = helpPath;
- this.state.ingressHelpPath = ingressHelpPath;
- this.state.ingressDnsHelpPath = ingressDnsHelpPath;
- this.state.ingressModSecurityHelpPath = ingressModSecurityHelpPath;
- this.state.environmentsHelpPath = environmentsHelpPath;
- this.state.clustersHelpPath = clustersHelpPath;
- this.state.deployBoardsHelpPath = deployBoardsHelpPath;
- this.state.cloudRunHelpPath = cloudRunHelpPath;
- this.state.ciliumHelpPath = ciliumHelpPath;
+ setHelpPaths(helpPaths) {
+ Object.assign(this.state, {
+ ...helpPaths,
+ });
}
setManagePrometheusPath(managePrometheusPath) {
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index 542890d9b04..b70f8d6e736 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -27,7 +27,7 @@ export default class ImageFile {
initViewModes() {
const viewMode = viewModes[0];
- $('.view-modes', this.file).removeClass('hide');
+ $('.view-modes', this.file).removeClass('gl-display-none');
$('.view-modes-menu', this.file).on('click', 'li', event => {
if (!$(event.currentTarget).hasClass('active')) {
return this.activateViewMode(event.currentTarget.className);
@@ -42,12 +42,10 @@ export default class ImageFile {
.filter(`.${viewMode}`)
.addClass('active');
- // eslint-disable-next-line no-jquery/no-fade
- return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => {
- // eslint-disable-next-line no-jquery/no-fade
- $(`.view.${viewMode}`, this.file).fadeIn(200);
- return this.initView(viewMode);
- });
+ $(`.view:visible:not(.${viewMode})`, this.file).addClass('gl-display-none');
+ $(`.view.${viewMode}`, this.file).removeClass('gl-display-none');
+
+ return this.initView(viewMode);
}
initView(viewMode) {
@@ -74,12 +72,14 @@ export default class ImageFile {
callback(e, left);
};
+ // eslint-disable-next-line @gitlab/no-global-event-off
$el
.off('mousedown')
.off('touchstart')
.on('mousedown', dragStart)
.on('touchstart', dragStart);
+ // eslint-disable-next-line @gitlab/no-global-event-off
$body
.off('mouseup')
.off('mousemove')
@@ -120,7 +120,7 @@ export default class ImageFile {
return this.requestImageInfo($('img', wrap), (width, height) => {
$('.image-info .meta-width', wrap).text(`${width}px`);
$('.image-info .meta-height', wrap).text(`${height}px`);
- return $('.image-info', wrap).removeClass('hide');
+ return $('.image-info', wrap).removeClass('gl-display-none');
});
});
},
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index 7dd75d03ab9..b18c109937d 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -31,7 +31,7 @@ export default class CommitsList {
const search = this.searchField.val();
if (search === this.lastSearch) return Promise.resolve();
const commitsUrl = `${form.attr('action')}?${form.serialize()}`;
- this.content.fadeTo('fast', 0.5);
+ this.content.addClass('gl-opacity-5');
const params = form.serializeArray().reduce(
(acc, obj) =>
Object.assign(acc, {
@@ -47,7 +47,7 @@ export default class CommitsList {
.then(({ data }) => {
this.lastSearch = search;
this.content.html(data.html);
- this.content.fadeTo('fast', 1.0);
+ this.content.removeClass('gl-opacity-5');
// Change url so if user reload a page - search results are saved
window.history.replaceState(
@@ -59,7 +59,7 @@ export default class CommitsList {
);
})
.catch(() => {
- this.content.fadeTo('fast', 1.0);
+ this.content.removeClass('gl-opacity-5');
this.lastSearch = null;
});
}
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index df0fa1ae88b..2a1244149ff 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -1,7 +1,14 @@
import $ from 'jquery';
// bootstrap jQuery plugins
-import 'bootstrap';
+import 'bootstrap/js/dist/alert';
+import 'bootstrap/js/dist/button';
+import 'bootstrap/js/dist/collapse';
+import 'bootstrap/js/dist/modal';
+import 'bootstrap/js/dist/dropdown';
+import 'bootstrap/js/dist/popover';
+import 'bootstrap/js/dist/tooltip';
+import 'bootstrap/js/dist/tab';
// custom jQuery functions
$.fn.extend({
diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js
index 7321e4d18cc..4f7bc829b0c 100644
--- a/app/assets/javascripts/confirm_danger_modal.js
+++ b/app/assets/javascripts/confirm_danger_modal.js
@@ -14,6 +14,7 @@ function openConfirmDangerModal($form, $modal, text) {
$submit.disable();
$input.focus();
+ // eslint-disable-next-line @gitlab/no-global-event-off
$input.off('input').on('input', function handleInput() {
const confirmText = rstrip($(this).val());
if (confirmText === confirmTextMatch) {
@@ -23,6 +24,7 @@ function openConfirmDangerModal($form, $modal, text) {
}
});
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.js-confirm-danger-submit', $modal)
.off('click')
.on('click', () => {
diff --git a/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue
index e9d484bdd94..1e3a19b9da1 100644
--- a/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue
@@ -154,6 +154,7 @@ export default {
});
},
beforeDestroy() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$(this.$refs.dropdown).off();
},
methods: {
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index 2858561e033..a7425735733 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -1,10 +1,17 @@
<script>
import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex';
-import { GlFormGroup, GlFormInput, GlFormCheckbox, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ GlFormGroup,
+ GlFormInput,
+ GlFormCheckbox,
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ GlButton,
+} from '@gitlab/ui';
import { s__ } from '~/locale';
import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
import { KUBERNETES_VERSIONS } from '../constants';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles');
const { mapState: mapKeyPairsState, mapActions: mapKeyPairsActions } = createNamespacedHelpers(
@@ -29,7 +36,7 @@ export default {
GlIcon,
GlLink,
GlSprintf,
- LoadingButton,
+ GlButton,
},
props: {
gitlabManagedClusterHelpPath: {
@@ -508,13 +515,16 @@ export default {
</p>
</div>
<div class="form-group">
- <loading-button
- class="js-create-cluster btn-success"
+ <gl-button
+ variant="success"
+ category="primary"
+ class="js-create-cluster"
:disabled="createClusterButtonDisabled"
:loading="isCreatingCluster"
- :label="createClusterButtonLabel"
@click="createCluster()"
- />
+ >
+ {{ createClusterButtonLabel }}
+ </gl-button>
</div>
</form>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
index f3950a3343a..b182d4dff13 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
@@ -42,7 +42,13 @@ export const createRole = ({ dispatch, state: { createRolePath } }, payload) =>
dispatch('createRoleSuccess', awsData);
})
- .catch(error => dispatch('createRoleError', { error }));
+ .catch(error => {
+ let message = error;
+ if (error?.response?.data?.message) {
+ message = error.response.data.message;
+ }
+ dispatch('createRoleError', { error: message });
+ });
};
export const requestCreateRole = ({ commit }) => {
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
index 85d9f0d66ab..522fef423af 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
@@ -179,13 +179,13 @@ export default {
'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral'
"
target="_blank"
- >{{ content }} <gl-icon name="external-link" aria-hidden="true"
+ >{{ content }} <gl-icon name="external-link"
/></gl-link>
</template>
<template #docsLink="{ content }">
<gl-link :href="docsUrl" target="_blank"
- >{{ content }} <gl-icon name="external-link" aria-hidden="true"
+ >{{ content }} <gl-icon name="external-link"
/></gl-link>
</template>
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 9c0ed7f79d4..0d53efe8689 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -29,11 +29,17 @@ export default class CreateLabelDropdown {
}
cleanBinding() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$colorSuggestions.off('click');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$newLabelField.off('keyup change');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$newColorField.off('keyup change');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$dropdownBack.off('click');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$cancelButton.off('click');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$newLabelCreateButton.off('click');
}
diff --git a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
index b2c9cd4e597..4c44aac4e2a 100644
--- a/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
+++ b/app/assets/javascripts/cycle_analytics/components/limit_warning_component.vue
@@ -27,7 +27,6 @@ export default {
),
}"
name="warning"
- aria-hidden="true"
/>
{{ n__('Showing %d event', 'Showing %d events', 50) }}
</span>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 4cccabca28b..70ebe91a3b2 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -74,6 +74,7 @@ export default () => {
const $dropdown = $('.js-ca-dropdown');
const $label = $dropdown.find('.dropdown-label');
+ // eslint-disable-next-line @gitlab/no-global-event-off
$dropdown
.find('li a')
.off('click')
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
index c17f2d2efe4..fe57dd2dc8f 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/gl_dropdown.js
@@ -622,6 +622,7 @@ export class GitLabDropdown {
// eslint-disable-next-line class-methods-use-this
removeArrowKeyEvent() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
return $('body').off('keydown');
}
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index e07279ba39d..fb86568c304 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -4,6 +4,7 @@ import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import allVersionsMixin from '../../mixins/all_versions';
import Toolbar from '../../components/toolbar/index.vue';
import DesignDestroyer from '../../components/design_destroyer.vue';
@@ -37,7 +38,7 @@ import {
TOGGLE_TODO_ERROR,
designDeletionError,
} from '../../utils/error_messages';
-import { trackDesignDetailView } from '../../utils/tracking';
+import { trackDesignDetailView, usagePingDesignDetailView } from '../../utils/tracking';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
import { ACTIVE_DISCUSSION_SOURCE_TYPES, DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../../constants';
@@ -55,7 +56,7 @@ export default {
GlAlert,
DesignSidebar,
},
- mixins: [allVersionsMixin],
+ mixins: [allVersionsMixin, glFeatureFlagsMixin()],
props: {
id: {
type: String,
@@ -150,7 +151,7 @@ export default {
},
mounted() {
Mousetrap.bind('esc', this.closeDesign);
- this.trackEvent();
+ this.trackPageViewEvent();
// Set active discussion immediately.
// This will ensure that, if a note is specified in the URL hash,
@@ -274,7 +275,7 @@ export default {
query: this.$route.query,
});
},
- trackEvent() {
+ trackPageViewEvent() {
// TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue
trackDesignDetailView(
'issue-design-collection',
@@ -282,6 +283,10 @@ export default {
this.$route.query.version || this.latestVersionId,
this.isLatestVersion,
);
+
+ if (this.glFeatures.usageDataDesignAction) {
+ usagePingDesignDetailView();
+ }
},
updateActiveDiscussion(id, source = ACTIVE_DISCUSSION_SOURCE_TYPES.discussion) {
this.$apollo.mutate({
diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js
index 4a39268c38b..37296f5b4ff 100644
--- a/app/assets/javascripts/design_management/utils/tracking.js
+++ b/app/assets/javascripts/design_management/utils/tracking.js
@@ -1,24 +1,34 @@
import Tracking from '~/tracking';
+import Api from '~/api';
-// Tracking Constants
+// Snowplow tracking constants
const DESIGN_TRACKING_CONTEXT_SCHEMAS = {
VIEW_DESIGN_SCHEMA: 'iglu:com.gitlab/design_management_context/jsonschema/1-0-0',
};
-const DESIGN_TRACKING_EVENTS = {
+
+export const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
+
+export const DESIGN_SNOWPLOW_EVENT_TYPES = {
VIEW_DESIGN: 'view_design',
CREATE_DESIGN: 'create_design',
UPDATE_DESIGN: 'update_design',
};
-export const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
+export const DESIGN_USAGE_PING_EVENT_TYPES = {
+ DESIGN_ACTION: 'design_action',
+};
+/**
+ * Track "design detail" view in Snowplow
+ */
export function trackDesignDetailView(
referer = '',
owner = '',
designVersion = 1,
latestVersion = false,
) {
- const eventName = DESIGN_TRACKING_EVENTS.VIEW_DESIGN;
+ const eventName = DESIGN_SNOWPLOW_EVENT_TYPES.VIEW_DESIGN;
+
Tracking.event(DESIGN_TRACKING_PAGE_NAME, eventName, {
label: eventName,
context: {
@@ -34,9 +44,16 @@ export function trackDesignDetailView(
}
export function trackDesignCreate() {
- return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.CREATE_DESIGN);
+ return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_SNOWPLOW_EVENT_TYPES.CREATE_DESIGN);
}
export function trackDesignUpdate() {
- return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_TRACKING_EVENTS.UPDATE_DESIGN);
+ return Tracking.event(DESIGN_TRACKING_PAGE_NAME, DESIGN_SNOWPLOW_EVENT_TYPES.UPDATE_DESIGN);
+}
+
+/**
+ * Track "design detail" view via usage ping
+ */
+export function usagePingDesignDetailView() {
+ Api.trackRedisHllUserEvent(DESIGN_USAGE_PING_EVENT_TYPES.DESIGN_ACTION);
}
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 9d8d184a3f6..7827c78b658 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -1,6 +1,7 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Mousetrap from 'mousetrap';
import { __ } from '~/locale';
import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
@@ -9,7 +10,10 @@ import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { updateHistory } from '~/lib/utils/url_utility';
-import eventHub from '../../notes/event_hub';
+
+import notesEventHub from '../../notes/event_hub';
+import eventHub from '../event_hub';
+
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
import NoChanges from './no_changes.vue';
@@ -21,6 +25,7 @@ import MergeConflictWarning from './merge_conflict_warning.vue';
import CollapsedFilesWarning from './collapsed_files_warning.vue';
import { diffsApp } from '../utils/performance';
+import { fileByFile } from '../utils/preferences';
import {
TREE_LIST_WIDTH_STORAGE_KEY,
@@ -33,6 +38,7 @@ import {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
+ EVT_VIEW_FILE_BY_FILE,
} from '../constants';
export default {
@@ -113,7 +119,7 @@ export default {
required: false,
default: false,
},
- viewDiffsFileByFile: {
+ fileByFileUserPreference: {
type: Boolean,
required: false,
default: false,
@@ -153,6 +159,7 @@ export default {
'conflictResolutionPath',
'canMerge',
'hasConflicts',
+ 'viewDiffsFileByFile',
]),
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
@@ -230,9 +237,6 @@ export default {
}
},
diffViewType() {
- if (!this.glFeatures.unifiedDiffLines && (this.needsReload() || this.needsFirstLoad())) {
- this.refetchDiffData();
- }
this.adjustView();
},
shouldShow() {
@@ -256,7 +260,7 @@ export default {
projectPath: this.projectPath,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
- viewDiffsFileByFile: this.viewDiffsFileByFile,
+ viewDiffsFileByFile: fileByFile(this.fileByFileUserPreference),
});
if (this.shouldShow) {
@@ -279,9 +283,8 @@ export default {
},
created() {
this.adjustView();
+ this.subscribeToEvents();
- eventHub.$once('fetchDiffData', this.fetchData);
- eventHub.$on('refetchDiffData', this.refetchDiffData);
this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
this.unwatchDiscussions = this.$watch(
@@ -301,9 +304,7 @@ export default {
},
beforeDestroy() {
diffsApp.deinstrument();
-
- eventHub.$off('fetchDiffData', this.fetchData);
- eventHub.$off('refetchDiffData', this.refetchDiffData);
+ this.unsubscribeFromEvents();
this.removeEventListeners();
},
methods: {
@@ -319,9 +320,23 @@ export default {
'setHighlightedRow',
'cacheTreeListWidth',
'scrollToFile',
- 'toggleShowTreeList',
+ 'setShowTreeList',
'navigateToDiffFileIndex',
+ 'setFileByFile',
]),
+ subscribeToEvents() {
+ notesEventHub.$once('fetchDiffData', this.fetchData);
+ notesEventHub.$on('refetchDiffData', this.refetchDiffData);
+ eventHub.$on(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener);
+ },
+ unsubscribeFromEvents() {
+ eventHub.$off(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener);
+ notesEventHub.$off('refetchDiffData', this.refetchDiffData);
+ notesEventHub.$off('fetchDiffData', this.fetchData);
+ },
+ fileByFileListener({ setting } = {}) {
+ this.setFileByFile({ fileByFile: setting });
+ },
navigateToDiffFileNumber(number) {
this.navigateToDiffFileIndex(number - 1);
},
@@ -346,7 +361,7 @@ export default {
this.fetchDiffFilesMeta()
.then(({ real_size }) => {
this.diffFilesLength = parseInt(real_size, 10);
- if (toggleTree) this.hideTreeListIfJustOneFile();
+ if (toggleTree) this.setTreeDisplay();
this.startDiffRendering();
})
@@ -356,6 +371,7 @@ export default {
this.fetchDiffFilesBatch()
.then(() => {
+ if (toggleTree) this.setTreeDisplay();
// Guarantee the discussions are assigned after the batch finishes.
// Just watching the length of the discussions or the diff files
// isn't enough, because with split diff loading, neither will
@@ -372,7 +388,7 @@ export default {
}
if (!this.isNotesFetched) {
- eventHub.$emit('fetchNotesData');
+ notesEventHub.$emit('fetchNotesData');
}
},
setDiscussions() {
@@ -425,12 +441,17 @@ export default {
this.scrollToFile(this.diffFiles[targetIndex].file_path);
}
},
- hideTreeListIfJustOneFile() {
+ setTreeDisplay() {
const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY);
+ let showTreeList = true;
- if ((storedTreeShow === null && this.diffFiles.length <= 1) || storedTreeShow === 'false') {
- this.toggleShowTreeList(false);
+ if (storedTreeShow !== null) {
+ showTreeList = parseBoolean(storedTreeShow);
+ } else if (!bp.isDesktop() || (!this.isBatchLoading && this.diffFiles.length <= 1)) {
+ showTreeList = false;
}
+
+ return this.setShowTreeList({ showTreeList, saving: false });
},
},
minTreeWidth: MIN_TREE_WIDTH,
@@ -521,6 +542,7 @@ export default {
<template #total>{{ diffFiles.length }}</template>
</gl-sprintf>
</div>
+ <gl-loading-icon v-else-if="retrievingBatches" size="lg" />
</template>
<no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" />
</div>
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 1b747fb7f20..a548354f257 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -136,7 +136,12 @@ export default {
class="d-inline-flex mb-2"
/>
<gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group">
- <gl-button label class="gl-font-monospace" v-text="commit.short_id" />
+ <gl-button
+ label
+ class="gl-font-monospace"
+ data-testid="commit-sha-short-id"
+ v-text="commit.short_id"
+ />
<clipboard-button
:text="commit.id"
:title="__('Copy commit SHA')"
diff --git a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
index adef5d94624..da34a7ee19b 100644
--- a/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
+++ b/app/assets/javascripts/diffs/components/compare_dropdown_layout.vue
@@ -1,10 +1,11 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
- GlIcon,
+ GlDropdown,
+ GlDropdownItem,
TimeAgo,
},
props: {
@@ -22,57 +23,35 @@ export default {
</script>
<template>
- <span class="dropdown inline">
- <a
- class="dropdown-menu-toggle btn btn-default w-100"
- data-toggle="dropdown"
- aria-expanded="false"
+ <gl-dropdown :text="selectedVersionName" data-qa-selector="dropdown_content">
+ <gl-dropdown-item
+ v-for="version in versions"
+ :key="version.id"
+ :class="{
+ 'is-active': version.selected,
+ }"
+ :is-check-item="true"
+ :is-checked="version.selected"
+ :href="version.href"
>
- <span> {{ selectedVersionName }} </span>
- <gl-icon :size="12" name="angle-down" class="position-absolute" />
- </a>
- <div class="dropdown-menu dropdown-select dropdown-menu-selectable">
- <div class="dropdown-content" data-qa-selector="dropdown_content">
- <ul>
- <li v-for="version in versions" :key="version.id">
- <a :class="{ 'is-active': version.selected }" :href="version.href">
- <div>
- <strong>
- {{ version.versionName }}
- <template v-if="version.isHead">{{
- s__('DiffsCompareBaseBranch|(HEAD)')
- }}</template>
- <template v-else-if="version.isBase">{{
- s__('DiffsCompareBaseBranch|(base)')
- }}</template>
- </strong>
- </div>
- <div>
- <small class="commit-sha"> {{ version.short_commit_sha }} </small>
- </div>
- <div>
- <small>
- <template v-if="version.commitsText">
- {{ version.commitsText }}
- </template>
- <time-ago
- v-if="version.created_at"
- :time="version.created_at"
- class="js-timeago"
- />
- </small>
- </div>
- </a>
- </li>
- </ul>
+ <div>
+ <strong>
+ {{ version.versionName }}
+ <template v-if="version.isHead">{{ s__('DiffsCompareBaseBranch|(HEAD)') }}</template>
+ <template v-else-if="version.isBase">{{ s__('DiffsCompareBaseBranch|(base)') }}</template>
+ </strong>
</div>
- </div>
- </span>
+ <div>
+ <small class="commit-sha"> {{ version.short_commit_sha }} </small>
+ </div>
+ <div>
+ <small>
+ <template v-if="version.commitsText">
+ {{ version.commitsText }}
+ </template>
+ <time-ago v-if="version.created_at" :time="version.created_at" class="js-timeago" />
+ </small>
+ </div>
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
-
-<style>
-.dropdown {
- min-width: 0;
- max-height: 170px;
-}
-</style>
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 700d5ec86c8..f3cc359a679 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -65,11 +65,7 @@ export default {
polyfillSticky(this.$el);
},
methods: {
- ...mapActions('diffs', [
- 'setInlineDiffViewType',
- 'setParallelDiffViewType',
- 'toggleShowTreeList',
- ]),
+ ...mapActions('diffs', ['setInlineDiffViewType', 'setParallelDiffViewType', 'setShowTreeList']),
expandAllFiles() {
eventHub.$emit(EVT_EXPAND_ALL_FILES);
},
@@ -92,7 +88,7 @@ export default {
class="gl-mr-3 js-toggle-tree-list"
:title="toggleFileBrowserTitle"
:selected="showTreeList"
- @click="toggleShowTreeList"
+ @click="setShowTreeList({ showTreeList: !showTreeList })"
/>
<gl-sprintf
v-if="showDropdowns"
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 401064fb18f..f938ea368d8 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -87,7 +87,7 @@ export default {
return this.getUserData;
},
mappedLines() {
- if (this.glFeatures.unifiedDiffLines && this.glFeatures.unifiedDiffComponents) {
+ if (this.glFeatures.unifiedDiffComponents) {
return this.diffLines(this.diffFile, true).map(mapParallel(this)) || [];
}
@@ -95,9 +95,7 @@ export default {
if (this.isInlineView) {
return this.diffFile.highlighted_diff_lines.map(mapInline(this));
}
- return this.glFeatures.unifiedDiffLines
- ? this.diffLines(this.diffFile).map(mapParallel(this))
- : this.diffFile.parallel_diff_lines.map(mapParallel(this)) || [];
+ return this.diffLines(this.diffFile).map(mapParallel(this));
},
},
updated() {
@@ -129,9 +127,7 @@ export default {
<template>
<div class="diff-content">
<div class="diff-viewer">
- <template
- v-if="isTextFile && glFeatures.unifiedDiffLines && glFeatures.unifiedDiffComponents"
- >
+ <template v-if="isTextFile && glFeatures.unifiedDiffComponents">
<diff-view
:diff-file="diffFile"
:diff-lines="mappedLines"
@@ -173,12 +169,16 @@ export default {
:a-mode="diffFile.a_mode"
:b-mode="diffFile.b_mode"
>
- <image-diff-overlay
- slot="image-overlay"
- :discussions="imageDiscussions"
- :file-hash="diffFileHash"
- :can-comment="getNoteableData.current_user.can_create_note && !diffFile.brokenSymlink"
- />
+ <template #image-overlay="{ renderedWidth, renderedHeight }">
+ <image-diff-overlay
+ v-if="renderedWidth"
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ :discussions="imageDiscussions"
+ :file-hash="diffFileHash"
+ :can-comment="getNoteableData.current_user.can_create_note && !diffFile.brokenSymlink"
+ />
+ </template>
<div v-if="showNotesContainer" class="note-container">
<user-avatar-link
v-if="diffFileCommentForm && author"
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
index 4c49dfb5de9..2401e12e4f6 100644
--- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -4,7 +4,7 @@ import { GlIcon } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
+import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, INLINE_DIFF_LINES_KEY } from '../constants';
import * as utils from '../store/utils';
const EXPAND_ALL = 0;
@@ -14,7 +14,6 @@ const EXPAND_DOWN = 2;
const lineNumberByViewType = (viewType, diffLine) => {
const numberGetters = {
[INLINE_DIFF_VIEW_TYPE]: line => line?.new_line,
- [PARALLEL_DIFF_VIEW_TYPE]: line => (line?.right || line?.left)?.new_line,
};
const numberGetter = numberGetters[viewType];
return numberGetter && numberGetter(diffLine);
@@ -57,9 +56,6 @@ export default {
},
computed: {
...mapState({
- diffViewType(state) {
- return this.glFeatures.unifiedDiffLines ? INLINE_DIFF_VIEW_TYPE : state.diffs.diffViewType;
- },
diffFiles: state => state.diffs.diffFiles,
}),
canExpandUp() {
@@ -77,16 +73,14 @@ export default {
...mapActions('diffs', ['loadMoreLines']),
getPrevLineNumber(oldLineNumber, newLineNumber) {
const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash);
- const lines = {
- [INLINE_DIFF_VIEW_TYPE]: diffFile.highlighted_diff_lines,
- [PARALLEL_DIFF_VIEW_TYPE]: diffFile.parallel_diff_lines,
- };
- const index = utils.getPreviousLineIndex(this.diffViewType, diffFile, {
+ const index = utils.getPreviousLineIndex(INLINE_DIFF_VIEW_TYPE, diffFile, {
oldLineNumber,
newLineNumber,
});
- return lineNumberByViewType(this.diffViewType, lines[this.diffViewType][index - 2]) || 0;
+ return (
+ lineNumberByViewType(INLINE_DIFF_VIEW_TYPE, diffFile[INLINE_DIFF_LINES_KEY][index - 2]) || 0
+ );
},
callLoadMoreLines(
endpoint,
@@ -113,7 +107,7 @@ export default {
this.isRequesting = true;
const endpoint = this.contextLinesPath;
const { fileHash } = this;
- const view = this.diffViewType;
+ const view = INLINE_DIFF_VIEW_TYPE;
const oldLineNumber = this.line.meta_data.old_pos || 0;
const newLineNumber = this.line.meta_data.new_pos || 0;
const offset = newLineNumber - oldLineNumber;
@@ -232,11 +226,11 @@ export default {
class="gl-mx-2 gl-cursor-pointer js-unfold-down gl-display-inline-block gl-py-4"
@click="handleExpandLines(EXPAND_DOWN)"
>
- <gl-icon :size="12" name="expand-down" aria-hidden="true" />
+ <gl-icon :size="12" name="expand-down" />
<span>{{ $options.i18n.showMore }}</span>
</a>
<a class="gl-mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()">
- <gl-icon :size="12" name="expand" aria-hidden="true" />
+ <gl-icon :size="12" name="expand" />
<span>{{ $options.i18n.showAll }}</span>
</a>
<a
@@ -244,7 +238,7 @@ export default {
class="gl-mx-2 gl-cursor-pointer js-unfold gl-display-inline-block gl-py-4"
@click="handleExpandLines(EXPAND_UP)"
>
- <gl-icon :size="12" name="expand-up" aria-hidden="true" />
+ <gl-icon :size="12" name="expand-up" />
<span>{{ $options.i18n.showMore }}</span>
</a>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 32191d7e309..ed94cabe124 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -10,7 +10,7 @@ import notesEventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
import { diffViewerErrors } from '~/ide/constants';
-import { collapsedType, isCollapsed } from '../diff_file';
+import { collapsedType, isCollapsed } from '../utils/diff_file';
import {
DIFF_FILE_AUTOMATIC_COLLAPSE,
DIFF_FILE_MANUAL_COLLAPSE,
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 0d99a2e8a60..53d1383b82e 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -19,7 +19,7 @@ import { __, s__, sprintf } from '~/locale';
import { diffViewerModes } from '~/ide/constants';
import DiffStats from './diff_stats.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
-import { isCollapsed } from '../diff_file';
+import { isCollapsed } from '../utils/diff_file';
import { DIFF_FILE_HEADER } from '../i18n';
export default {
@@ -221,7 +221,6 @@ export default {
ref="collapseIcon"
:name="collapseIcon"
:size="16"
- aria-hidden="true"
class="diff-toggle-caret gl-mr-2"
@click.stop="handleToggleFile"
/>
diff --git a/app/assets/javascripts/diffs/components/diff_file_row.vue b/app/assets/javascripts/diffs/components/diff_file_row.vue
index 3888eb781fb..6c5d9170c9e 100644
--- a/app/assets/javascripts/diffs/components/diff_file_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_row.vue
@@ -41,10 +41,6 @@ export default {
return !this.hideFileStats && this.file.type === 'blob';
},
fileClasses() {
- if (!this.glFeatures.highlightCurrentDiffRow) {
- return '';
- }
-
return this.file.type === 'blob' && !this.viewedFiles[this.file.fileHash]
? 'gl-font-weight-bold'
: '';
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 55f5a736cdf..172a2bdde7d 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -7,7 +7,7 @@ import noteForm from '../../notes/components/note_form.vue';
import MultilineCommentForm from '../../notes/components/multiline_comment_form.vue';
import autosave from '../../notes/mixins/autosave';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-import { DIFF_NOTE_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
+import { DIFF_NOTE_TYPE, INLINE_DIFF_LINES_KEY, PARALLEL_DIFF_VIEW_TYPE } from '../constants';
import {
commentLineOptions,
formatLineRange,
@@ -102,13 +102,13 @@ export default {
};
const getDiffLines = () => {
if (this.diffViewType === PARALLEL_DIFF_VIEW_TYPE) {
- return (this.glFeatures.unifiedDiffLines
- ? this.diffLines(this.diffFile)
- : this.diffFile.parallel_diff_lines
- ).reduce(combineSides, []);
+ return this.diffLines(this.diffFile, this.glFeatures.unifiedDiffComponents).reduce(
+ combineSides,
+ [],
+ );
}
- return this.diffFile.highlighted_diff_lines;
+ return this.diffFile[INLINE_DIFF_LINES_KEY];
};
const side = this.line.type === 'new' ? 'right' : 'left';
const lines = getDiffLines();
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 77a97c67f3b..c0719e2a7d9 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -157,10 +157,10 @@ export default {
"
/>
</div>
- <div :class="classNameMapCellLeft" class="diff-td diff-line-num old_line">
+ <div v-if="inline" :class="classNameMapCellLeft" class="diff-td diff-line-num old_line">
<a
- v-if="line.left.old_line"
- :data-linenumber="line.left.old_line"
+ v-if="line.left.new_line"
+ :data-linenumber="line.left.new_line"
:href="line.lineHrefOld"
@click="setHighlightedRow(line.lineCode)"
>
@@ -179,21 +179,14 @@ export default {
</template>
<template v-else>
<div data-testid="leftEmptyCell" class="diff-td diff-line-num old_line empty-cell"></div>
- <div class="diff-td diff-line-num old_line empty-cell"></div>
+ <div v-if="inline" class="diff-td diff-line-num old_line empty-cell"></div>
<div class="diff-td line-coverage left-side empty-cell"></div>
<div class="diff-td line_content with-coverage parallel left-side empty-cell"></div>
</template>
</div>
- <div
- v-if="!inline || (line.right && Boolean(line.right.type))"
- class="diff-grid-right right-side"
- >
+ <div v-if="!inline" class="diff-grid-right right-side">
<template v-if="line.right">
- <div
- :class="classNameMapCellRight"
- data-testid="rightLineNumber"
- class="diff-td diff-line-num new_line"
- >
+ <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line">
<span
v-if="shouldRenderCommentButton"
v-gl-tooltip
@@ -231,15 +224,6 @@ export default {
"
/>
</div>
- <div :class="classNameMapCellRight" class="diff-td diff-line-num new_line">
- <a
- v-if="line.right.new_line"
- :data-linenumber="line.right.new_line"
- :href="line.lineHrefNew"
- @click="setHighlightedRow(line.lineCode)"
- >
- </a>
- </div>
<div
v-gl-tooltip.hover
:title="coverageState.text"
diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
index 3956c2fab49..6a1e0d8cbd6 100644
--- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue
+++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
@@ -4,6 +4,10 @@ import { isArray } from 'lodash';
import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff';
import { GlIcon } from '@gitlab/ui';
+function calcPercent(pos, size, renderedSize) {
+ return (((pos / size) * 100) / ((renderedSize / size) * 100)) * 100;
+}
+
export default {
name: 'ImageDiffOverlay',
components: {
@@ -39,6 +43,14 @@ export default {
required: false,
default: true,
},
+ renderedWidth: {
+ type: Number,
+ required: true,
+ },
+ renderedHeight: {
+ type: Number,
+ required: true,
+ },
},
computed: {
...mapGetters('diffs', ['getDiffFileByHash', 'getCommentFormForDiffFile']),
@@ -59,33 +71,33 @@ export default {
},
getPositionForObject(meta) {
const { x, y, width, height } = meta;
- const imageWidth = this.getImageDimensions().width;
- const imageHeight = this.getImageDimensions().height;
- const widthRatio = imageWidth / width;
- const heightRatio = imageHeight / height;
return {
- x: Math.round(x * widthRatio),
- y: Math.round(y * heightRatio),
+ x: (x / width) * 100,
+ y: (y / height) * 100,
};
},
getPosition(discussion) {
const { x, y } = this.getPositionForObject(discussion.position);
return {
- left: `${x}px`,
- top: `${y}px`,
+ left: `${x}%`,
+ top: `${y}%`,
};
},
clickedImage(x, y) {
const { width, height } = this.getImageDimensions();
+ const xPercent = calcPercent(x, width, this.renderedWidth);
+ const yPercent = calcPercent(y, height, this.renderedHeight);
this.openDiffFileCommentForm({
fileHash: this.fileHash,
width,
height,
- x,
- y,
+ x: width * (xPercent / 100),
+ y: height * (yPercent / 100),
+ xPercent,
+ yPercent,
});
},
},
@@ -112,22 +124,19 @@ export default {
type="button"
@click="clickedToggle(discussion)"
>
- <gl-icon v-if="showCommentIcon" name="image-comment-dark" />
+ <gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" />
<template v-else>
{{ toggleText(discussion, index) }}
</template>
</button>
<button
- v-if="currentCommentForm"
- :style="{
- left: `${currentCommentForm.x}px`,
- top: `${currentCommentForm.y}px`,
- }"
+ v-if="canComment && currentCommentForm"
+ :style="{ left: `${currentCommentForm.xPercent}%`, top: `${currentCommentForm.yPercent}%` }"
:aria-label="__('Comment form position')"
- class="btn-transparent comment-indicator"
+ class="btn-transparent comment-indicator position-absolute"
type="button"
>
- <gl-icon name="image-comment-dark" />
+ <gl-icon name="image-comment-dark" :size="24" />
</button>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
index e47bea8e589..587efd6ed41 100644
--- a/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
+++ b/app/assets/javascripts/diffs/components/merge_conflict_warning.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlAlert } from '@gitlab/ui';
+import { GlButton, GlAlert, GlModalDirective } from '@gitlab/ui';
import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants';
export default {
@@ -7,6 +7,9 @@ export default {
GlAlert,
GlButton,
},
+ directives: {
+ GlModalDirective,
+ },
props: {
limited: {
type: Boolean,
@@ -60,9 +63,8 @@ export default {
</gl-button>
<gl-button
v-if="mergeable"
+ v-gl-modal-directive="'modal-merge-info'"
class="gl-alert-action"
- data-toggle="modal"
- data-target="#modal_merge_info"
>
{{ __('Merge locally') }}
</gl-button>
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
index 78647065c8e..2fe2fd6b3d8 100644
--- a/app/assets/javascripts/diffs/components/settings_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -1,23 +1,22 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { GlButtonGroup, GlButton, GlDropdown } from '@gitlab/ui';
-import { __ } from '~/locale';
+import { GlButtonGroup, GlButton, GlDropdown, GlFormCheckbox } from '@gitlab/ui';
+
+import eventHub from '../event_hub';
+import { EVT_VIEW_FILE_BY_FILE } from '../constants';
+import { SETTINGS_DROPDOWN } from '../i18n';
export default {
+ i18n: SETTINGS_DROPDOWN,
components: {
GlButtonGroup,
GlButton,
GlDropdown,
+ GlFormCheckbox,
},
computed: {
...mapGetters('diffs', ['isInlineView', 'isParallelView']),
- ...mapState('diffs', ['renderTreeList', 'showWhitespace']),
- },
- mounted() {
- this.patchAriaLabel();
- },
- updated() {
- this.patchAriaLabel();
+ ...mapState('diffs', ['renderTreeList', 'showWhitespace', 'viewDiffsFileByFile']),
},
methods: {
...mapActions('diffs', [
@@ -26,17 +25,21 @@ export default {
'setRenderTreeList',
'setShowWhitespace',
]),
- patchAriaLabel() {
- this.$el
- .querySelector('.js-show-diff-settings')
- .setAttribute('aria-label', __('Diff view settings'));
+ toggleFileByFile() {
+ eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting: !this.viewDiffsFileByFile });
},
},
};
</script>
<template>
- <gl-dropdown icon="settings" toggle-class="js-show-diff-settings" right>
+ <gl-dropdown
+ icon="settings"
+ :text="__('Diff view settings')"
+ :text-sr-only="true"
+ toggle-class="js-show-diff-settings"
+ right
+ >
<div class="gl-px-3">
<span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span>
<gl-button-group class="gl-display-flex">
@@ -90,5 +93,15 @@ export default {
{{ __('Show whitespace changes') }}
</label>
</div>
+ <div class="gl-mt-3 gl-px-3">
+ <gl-form-checkbox
+ data-testid="file-by-file"
+ class="gl-mb-0"
+ :checked="viewDiffsFileByFile"
+ @input="toggleFileByFile"
+ >
+ {{ $options.i18n.fileByFile }}
+ </gl-form-checkbox>
+ </div>
</gl-dropdown>
</template>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 79f8c08e389..07e27bd8e47 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -77,6 +77,11 @@ export const ALERT_COLLAPSED_FILES = 'collapsed';
export const DIFF_FILE_AUTOMATIC_COLLAPSE = 'automatic';
export const DIFF_FILE_MANUAL_COLLAPSE = 'manual';
+// Diff view single file mode
+export const DIFF_FILE_BY_FILE_COOKIE_NAME = 'fileViewMode';
+export const DIFF_VIEW_FILE_BY_FILE = 'single';
+export const DIFF_VIEW_ALL_FILES = 'all';
+
// State machine states
export const STATE_IDLING = 'idle';
export const STATE_LOADING = 'loading';
@@ -98,6 +103,7 @@ export const RENAMED_DIFF_TRANSITIONS = {
// MR Diffs known events
export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
+export const EVT_VIEW_FILE_BY_FILE = 'mr:diffs:preference:fileByFile';
export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart';
diff --git a/app/assets/javascripts/diffs/i18n.js b/app/assets/javascripts/diffs/i18n.js
index 4ec24d452bf..c4ac99ead91 100644
--- a/app/assets/javascripts/diffs/i18n.js
+++ b/app/assets/javascripts/diffs/i18n.js
@@ -16,3 +16,7 @@ export const DIFF_FILE = {
autoCollapsed: __('Files with large changes are collapsed by default.'),
expand: __('Expand file'),
};
+
+export const SETTINGS_DROPDOWN = {
+ fileByFile: __('Show one file at a time'),
+};
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 06a138b1e13..587220488be 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -116,7 +116,7 @@ export default function initDiffsApp(store) {
isFluidLayout: this.isFluidLayout,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
- viewDiffsFileByFile: this.viewDiffsFileByFile,
+ fileByFileUserPreference: this.viewDiffsFileByFile,
},
});
},
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 91c4c51487f..5b410051705 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -30,13 +30,11 @@ import {
OLD_LINE_KEY,
NEW_LINE_KEY,
TYPE_KEY,
- LEFT_LINE_KEY,
MAX_RENDERING_DIFF_LINES,
MAX_RENDERING_BULK_ROWS,
MIN_RENDERING_MS,
START_RENDERING_INDEX,
INLINE_DIFF_LINES_KEY,
- PARALLEL_DIFF_LINES_KEY,
DIFFS_PER_PAGE,
DIFF_WHITESPACE_COOKIE_NAME,
SHOW_WHITESPACE,
@@ -46,9 +44,12 @@ import {
EVT_PERF_MARK_FILE_TREE_START,
EVT_PERF_MARK_FILE_TREE_END,
EVT_PERF_MARK_DIFF_FILES_START,
+ DIFF_VIEW_FILE_BY_FILE,
+ DIFF_VIEW_ALL_FILES,
+ DIFF_FILE_BY_FILE_COOKIE_NAME,
} from '../constants';
import { diffViewerModes } from '~/ide/constants';
-import { isCollapsed } from '../diff_file';
+import { isCollapsed } from '../utils/diff_file';
export const setBaseConfig = ({ commit }, options) => {
const {
@@ -59,6 +60,7 @@ export const setBaseConfig = ({ commit }, options) => {
projectPath,
dismissEndpoint,
showSuggestPopover,
+ viewDiffsFileByFile,
} = options;
commit(types.SET_BASE_CONFIG, {
endpoint,
@@ -68,26 +70,38 @@ export const setBaseConfig = ({ commit }, options) => {
projectPath,
dismissEndpoint,
showSuggestPopover,
+ viewDiffsFileByFile,
});
};
export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
+ const diffsGradualLoad = window.gon?.features?.diffsGradualLoad;
+ let perPage = DIFFS_PER_PAGE;
+ let increaseAmount = 1.4;
+
+ if (diffsGradualLoad) {
+ perPage = state.viewDiffsFileByFile ? 1 : 5;
+ }
+
+ const startPage = diffsGradualLoad ? 0 : 1;
const id = window?.location?.hash;
const isNoteLink = id.indexOf('#note') === 0;
const urlParams = {
- per_page: DIFFS_PER_PAGE,
w: state.showWhitespace ? '0' : '1',
- view: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType,
+ view: 'inline',
};
+ let totalLoaded = 0;
commit(types.SET_BATCH_LOADING, true);
commit(types.SET_RETRIEVING_BATCHES, true);
eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START);
- const getBatch = (page = 1) =>
+ const getBatch = (page = startPage) =>
axios
- .get(mergeUrlParams({ ...urlParams, page }, state.endpointBatch))
+ .get(mergeUrlParams({ ...urlParams, page, per_page: perPage }, state.endpointBatch))
.then(({ data: { pagination, diff_files } }) => {
+ totalLoaded += diff_files.length;
+
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
commit(types.SET_BATCH_LOADING, false);
@@ -99,7 +113,11 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
dispatch('setCurrentDiffFileIdFromNote', id.split('_').pop());
}
- if (!pagination.next_page) {
+ if (
+ (diffsGradualLoad &&
+ (totalLoaded === pagination.total_pages || pagination.total_pages === null)) ||
+ (!diffsGradualLoad && !pagination.next_page)
+ ) {
commit(types.SET_RETRIEVING_BATCHES, false);
// We need to check that the currentDiffFileId points to a file that exists
@@ -125,6 +143,16 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
}),
);
}
+
+ return null;
+ }
+
+ if (diffsGradualLoad) {
+ const nextPage = page + perPage;
+ perPage = Math.min(Math.ceil(perPage * increaseAmount), 30);
+ increaseAmount = Math.min(increaseAmount + 0.2, 2);
+
+ return nextPage;
}
return pagination.next_page;
@@ -140,7 +168,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
export const fetchDiffFilesMeta = ({ commit, state }) => {
const worker = new TreeWorker();
const urlParams = {
- view: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType,
+ view: 'inline',
};
commit(types.SET_LOADING, true);
@@ -157,13 +185,19 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
.get(mergeUrlParams(urlParams, state.endpointMetadata))
.then(({ data }) => {
const strippedData = { ...data };
-
delete strippedData.diff_files;
+
commit(types.SET_LOADING, false);
commit(types.SET_MERGE_REQUEST_DIFFS, data.merge_request_diffs || []);
- commit(types.SET_DIFF_DATA, strippedData);
+ commit(types.SET_DIFF_METADATA, strippedData);
- worker.postMessage(prepareDiffData(data, state.diffFiles));
+ worker.postMessage(
+ prepareDiffData({
+ diff: data,
+ priorFiles: state.diffFiles,
+ meta: true,
+ }),
+ );
return data;
})
@@ -401,15 +435,10 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => {
export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff);
const lineCodesWithDiscussions = new Set();
- const { parallel_diff_lines: parallelLines, highlighted_diff_lines: inlineLines } = diff;
- const allLines = inlineLines.concat(
- parallelLines.map(line => line.left),
- parallelLines.map(line => line.right),
- );
const lineHasDiscussion = line => Boolean(line?.discussions.length);
const registerDiscussionLine = line => lineCodesWithDiscussions.add(line.line_code);
- allLines.filter(lineHasDiscussion).forEach(registerDiscussionLine);
+ diff[INLINE_DIFF_LINES_KEY].filter(lineHasDiscussion).forEach(registerDiscussionLine);
if (lineCodesWithDiscussions.size) {
Array.from(lineCodesWithDiscussions).forEach(lineCode => {
@@ -454,11 +483,11 @@ export const scrollToFile = ({ state, commit }, path) => {
commit(types.VIEW_DIFF_FILE, fileHash);
};
-export const toggleShowTreeList = ({ commit, state }, saving = true) => {
- commit(types.TOGGLE_SHOW_TREE_LIST);
+export const setShowTreeList = ({ commit }, { showTreeList, saving = true }) => {
+ commit(types.SET_SHOW_TREE_LIST, showTreeList);
if (saving) {
- localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList);
+ localStorage.setItem(MR_TREE_SHOW_KEY, showTreeList);
}
};
@@ -508,61 +537,26 @@ export const receiveFullDiffError = ({ commit }, filePath) => {
createFlash(s__('MergeRequest|Error loading full diff. Please try again.'));
};
-export const setExpandedDiffLines = ({ commit, state }, { file, data }) => {
- const expandedDiffLines = {
- highlighted_diff_lines: convertExpandLines({
- diffLines: file.highlighted_diff_lines,
- typeKey: TYPE_KEY,
- oldLineKey: OLD_LINE_KEY,
- newLineKey: NEW_LINE_KEY,
- data,
- mapLine: ({ line, oldLine, newLine }) =>
- Object.assign(line, {
- old_line: oldLine,
- new_line: newLine,
- line_code: `${file.file_hash}_${oldLine}_${newLine}`,
- }),
- }),
- parallel_diff_lines: convertExpandLines({
- diffLines: file.parallel_diff_lines,
- typeKey: [LEFT_LINE_KEY, TYPE_KEY],
- oldLineKey: [LEFT_LINE_KEY, OLD_LINE_KEY],
- newLineKey: [LEFT_LINE_KEY, NEW_LINE_KEY],
- data,
- mapLine: ({ line, oldLine, newLine }) => ({
- left: {
- ...line,
- old_line: oldLine,
- line_code: `${file.file_hash}_${oldLine}_${newLine}`,
- },
- right: {
- ...line,
- new_line: newLine,
- line_code: `${file.file_hash}_${newLine}_${oldLine}`,
- },
+export const setExpandedDiffLines = ({ commit }, { file, data }) => {
+ const expandedDiffLines = convertExpandLines({
+ diffLines: file[INLINE_DIFF_LINES_KEY],
+ typeKey: TYPE_KEY,
+ oldLineKey: OLD_LINE_KEY,
+ newLineKey: NEW_LINE_KEY,
+ data,
+ mapLine: ({ line, oldLine, newLine }) =>
+ Object.assign(line, {
+ old_line: oldLine,
+ new_line: newLine,
+ line_code: `${file.file_hash}_${oldLine}_${newLine}`,
}),
- }),
- };
- const unifiedDiffLinesEnabled = window.gon?.features?.unifiedDiffLines;
- const currentDiffLinesKey =
- state.diffViewType === INLINE_DIFF_VIEW_TYPE || unifiedDiffLinesEnabled
- ? INLINE_DIFF_LINES_KEY
- : PARALLEL_DIFF_LINES_KEY;
- const hiddenDiffLinesKey =
- state.diffViewType === INLINE_DIFF_VIEW_TYPE ? PARALLEL_DIFF_LINES_KEY : INLINE_DIFF_LINES_KEY;
-
- if (!unifiedDiffLinesEnabled) {
- commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, {
- filePath: file.file_path,
- lines: expandedDiffLines[hiddenDiffLinesKey],
- });
- }
+ });
- if (expandedDiffLines[currentDiffLinesKey].length > MAX_RENDERING_DIFF_LINES) {
+ if (expandedDiffLines.length > MAX_RENDERING_DIFF_LINES) {
let index = START_RENDERING_INDEX;
commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, {
filePath: file.file_path,
- lines: expandedDiffLines[currentDiffLinesKey].slice(0, index),
+ lines: expandedDiffLines.slice(0, index),
});
commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path);
@@ -571,10 +565,10 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => {
while (
t.timeRemaining() >= MIN_RENDERING_MS &&
- index !== expandedDiffLines[currentDiffLinesKey].length &&
+ index !== expandedDiffLines.length &&
index - startIndex !== MAX_RENDERING_BULK_ROWS
) {
- const line = expandedDiffLines[currentDiffLinesKey][index];
+ const line = expandedDiffLines[index];
if (line) {
commit(types.ADD_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, line });
@@ -582,7 +576,7 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => {
}
}
- if (index !== expandedDiffLines[currentDiffLinesKey].length) {
+ if (index !== expandedDiffLines.length) {
idleCallback(idleCb);
} else {
commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path);
@@ -593,7 +587,7 @@ export const setExpandedDiffLines = ({ commit, state }, { file, data }) => {
} else {
commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, {
filePath: file.file_path,
- lines: expandedDiffLines[currentDiffLinesKey],
+ lines: expandedDiffLines,
});
}
};
@@ -627,7 +621,7 @@ export const toggleFullDiff = ({ dispatch, commit, getters, state }, filePath) =
}
};
-export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { diffFile }) {
+export function switchToFullDiffFromRenamedFile({ commit, dispatch }, { diffFile }) {
return axios
.get(diffFile.context_lines_path, {
params: {
@@ -638,7 +632,7 @@ export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { d
.then(({ data }) => {
const lines = data.map((line, index) =>
prepareLineForRenamedFile({
- diffViewType: window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType,
+ diffViewType: 'inline',
line,
diffFile,
index,
@@ -736,3 +730,14 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
commit(types.VIEW_DIFF_FILE, fileHash);
};
+
+export const setFileByFile = ({ 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);
+
+ historyPushState(
+ mergeUrlParams({ [DIFF_FILE_BY_FILE_COOKIE_NAME]: fileViewMode }, window.location.href),
+ );
+};
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 9ee73998177..baf54188932 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -1,6 +1,10 @@
import { __, n__ } from '~/locale';
import { parallelizeDiffLines } from './utils';
-import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '../constants';
+import {
+ PARALLEL_DIFF_VIEW_TYPE,
+ INLINE_DIFF_VIEW_TYPE,
+ INLINE_DIFF_LINES_KEY,
+} from '../constants';
export * from './getters_versions_dropdowns';
@@ -54,24 +58,10 @@ export const diffHasAllCollapsedDiscussions = (state, getters) => diff => {
* @param {Object} diff
* @returns {Boolean}
*/
-export const diffHasExpandedDiscussions = state => diff => {
- const lines = {
- [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [],
- [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => {
- if (line.left) {
- acc.push(line.left);
- }
-
- if (line.right) {
- acc.push(line.right);
- }
-
- return acc;
- }, []),
- };
- return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType]
- .filter(l => l.discussions.length >= 1)
- .some(l => l.discussionsExpanded);
+export const diffHasExpandedDiscussions = () => diff => {
+ return diff[INLINE_DIFF_LINES_KEY].filter(l => l.discussions.length >= 1).some(
+ l => l.discussionsExpanded,
+ );
};
/**
@@ -79,24 +69,8 @@ export const diffHasExpandedDiscussions = state => diff => {
* @param {Boolean} diff
* @returns {Boolean}
*/
-export const diffHasDiscussions = state => diff => {
- const lines = {
- [INLINE_DIFF_VIEW_TYPE]: diff.highlighted_diff_lines || [],
- [PARALLEL_DIFF_VIEW_TYPE]: (diff.parallel_diff_lines || []).reduce((acc, line) => {
- if (line.left) {
- acc.push(line.left);
- }
-
- if (line.right) {
- acc.push(line.right);
- }
-
- return acc;
- }, []),
- };
- return lines[window.gon?.features?.unifiedDiffLines ? 'inline' : state.diffViewType].some(
- l => l.discussions.length >= 1,
- );
+export const diffHasDiscussions = () => diff => {
+ return diff[INLINE_DIFF_LINES_KEY].some(l => l.discussions.length >= 1);
};
/**
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 001d9d9f594..c331e52c887 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -5,6 +5,8 @@ import {
DIFF_VIEW_COOKIE_NAME,
DIFF_WHITESPACE_COOKIE_NAME,
} from '../../constants';
+
+import { fileByFile } from '../../utils/preferences';
import { getDefaultWhitespace } from '../utils';
const viewTypeFromQueryString = getParameterValues('view')[0];
@@ -39,6 +41,7 @@ export default () => ({
highlightedRow: null,
renderTreeList: true,
showWhitespace: getDefaultWhitespace(whiteSpaceFromQueryString, whiteSpaceFromCookie),
+ viewDiffsFileByFile: fileByFile(),
fileFinderVisible: false,
dismissEndpoint: '',
showSuggestPopover: true,
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 19a9e65edc9..30097239aaa 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -3,7 +3,7 @@ export const SET_LOADING = 'SET_LOADING';
export const SET_BATCH_LOADING = 'SET_BATCH_LOADING';
export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES';
-export const SET_DIFF_DATA = 'SET_DIFF_DATA';
+export const SET_DIFF_METADATA = 'SET_DIFF_METADATA';
export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH';
export const SET_DIFF_FILES = 'SET_DIFF_FILES';
@@ -17,7 +17,7 @@ export const RENDER_FILE = 'RENDER_FILE';
export const SET_LINE_DISCUSSIONS_FOR_FILE = 'SET_LINE_DISCUSSIONS_FOR_FILE';
export const REMOVE_LINE_DISCUSSIONS_FOR_FILE = 'REMOVE_LINE_DISCUSSIONS_FOR_FILE';
export const TOGGLE_FOLDER_OPEN = 'TOGGLE_FOLDER_OPEN';
-export const TOGGLE_SHOW_TREE_LIST = 'TOGGLE_SHOW_TREE_LIST';
+export const SET_SHOW_TREE_LIST = 'SET_SHOW_TREE_LIST';
export const VIEW_DIFF_FILE = 'VIEW_DIFF_FILE';
export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM';
@@ -28,6 +28,7 @@ export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW';
export const SET_TREE_DATA = 'SET_TREE_DATA';
export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST';
export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE';
+export const SET_FILE_BY_FILE = 'SET_FILE_BY_FILE';
export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE';
export const REQUEST_FULL_DIFF = 'REQUEST_FULL_DIFF';
@@ -35,7 +36,6 @@ export const RECEIVE_FULL_DIFF_SUCCESS = 'RECEIVE_FULL_DIFF_SUCCESS';
export const RECEIVE_FULL_DIFF_ERROR = 'RECEIVE_FULL_DIFF_ERROR';
export const SET_FILE_COLLAPSED = 'SET_FILE_COLLAPSED';
-export const SET_HIDDEN_VIEW_DIFF_FILE_LINES = 'SET_HIDDEN_VIEW_DIFF_FILE_LINES';
export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINES';
export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES';
export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 096c4f69439..19122c3096f 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -1,11 +1,6 @@
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import {
- DIFF_FILE_MANUAL_COLLAPSE,
- DIFF_FILE_AUTOMATIC_COLLAPSE,
- INLINE_DIFF_VIEW_TYPE,
-} from '../constants';
-import {
findDiffFile,
addLineReferences,
removeMatchLine,
@@ -14,6 +9,11 @@ import {
isDiscussionApplicableToLine,
updateLineInFile,
} from './utils';
+import {
+ DIFF_FILE_MANUAL_COLLAPSE,
+ DIFF_FILE_AUTOMATIC_COLLAPSE,
+ INLINE_DIFF_LINES_KEY,
+} from '../constants';
import * as types from './mutation_types';
function updateDiffFilesInState(state, files) {
@@ -36,6 +36,7 @@ export default {
projectPath,
dismissEndpoint,
showSuggestPopover,
+ viewDiffsFileByFile,
} = options;
Object.assign(state, {
endpoint,
@@ -45,6 +46,7 @@ export default {
projectPath,
dismissEndpoint,
showSuggestPopover,
+ viewDiffsFileByFile,
});
},
@@ -64,21 +66,17 @@ export default {
updateDiffFilesInState(state, files);
},
- [types.SET_DIFF_DATA](state, data) {
- let files = state.diffFiles;
-
- if (window.location.search.indexOf('diff_id') !== -1 && data.diff_files) {
- files = prepareDiffData(data, files);
- }
-
+ [types.SET_DIFF_METADATA](state, data) {
Object.assign(state, {
...convertObjectPropsToCamelCase(data),
});
- updateDiffFilesInState(state, files);
},
[types.SET_DIFF_DATA_BATCH](state, data) {
- const files = prepareDiffData(data, state.diffFiles);
+ const files = prepareDiffData({
+ diff: data,
+ priorFiles: state.diffFiles,
+ });
Object.assign(state, {
...convertObjectPropsToCamelCase(data),
@@ -109,25 +107,7 @@ export default {
if (!diffFile) return;
- if (diffFile.highlighted_diff_lines.length) {
- diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm;
- }
-
- if (diffFile.parallel_diff_lines.length) {
- const line = diffFile.parallel_diff_lines.find(l => {
- const { left, right } = l;
-
- return (left && left.line_code === lineCode) || (right && right.line_code === lineCode);
- });
-
- if (line.left && line.left.line_code === lineCode) {
- line.left.hasForm = hasForm;
- }
-
- if (line.right && line.right.line_code === lineCode) {
- line.right.hasForm = hasForm;
- }
- }
+ diffFile[INLINE_DIFF_LINES_KEY].find(l => l.line_code === lineCode).hasForm = hasForm;
},
[types.ADD_CONTEXT_LINES](state, options) {
@@ -157,11 +137,7 @@ export default {
});
addContextLines({
- inlineLines: diffFile.highlighted_diff_lines,
- parallelLines: diffFile.parallel_diff_lines,
- diffViewType: window.gon?.features?.unifiedDiffLines
- ? INLINE_DIFF_VIEW_TYPE
- : state.diffViewType,
+ inlineLines: diffFile[INLINE_DIFF_LINES_KEY],
contextLines: lines,
bottom,
lineNumbers,
@@ -170,7 +146,7 @@ export default {
},
[types.ADD_COLLAPSED_DIFFS](state, { file, data }) {
- const files = prepareDiffData(data);
+ const files = prepareDiffData({ diff: data });
const [newFileData] = files.filter(f => f.file_hash === file.file_hash);
const selectedFile = state.diffFiles.find(f => f.file_hash === file.file_hash);
Object.assign(selectedFile, { ...newFileData });
@@ -219,8 +195,8 @@ export default {
state.diffFiles.forEach(file => {
if (file.file_hash === fileHash) {
- if (file.highlighted_diff_lines.length) {
- file.highlighted_diff_lines.forEach(line => {
+ if (file[INLINE_DIFF_LINES_KEY].length) {
+ file[INLINE_DIFF_LINES_KEY].forEach(line => {
Object.assign(
line,
setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
@@ -228,25 +204,7 @@ export default {
});
}
- if (file.parallel_diff_lines.length) {
- file.parallel_diff_lines.forEach(line => {
- const left = line.left && lineCheck(line.left);
- const right = line.right && lineCheck(line.right);
-
- if (left || right) {
- Object.assign(line, {
- left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null,
- right: line.right
- ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left))
- : null,
- });
- }
-
- return line;
- });
- }
-
- if (!file.parallel_diff_lines.length || !file.highlighted_diff_lines.length) {
+ if (!file[INLINE_DIFF_LINES_KEY].length) {
const newDiscussions = (file.discussions || [])
.filter(d => d.id !== discussion.id)
.concat(discussion);
@@ -287,8 +245,8 @@ export default {
[types.TOGGLE_FOLDER_OPEN](state, path) {
state.treeEntries[path].opened = !state.treeEntries[path].opened;
},
- [types.TOGGLE_SHOW_TREE_LIST](state) {
- state.showTreeList = !state.showTreeList;
+ [types.SET_SHOW_TREE_LIST](state, showTreeList) {
+ state.showTreeList = showTreeList;
},
[types.VIEW_DIFF_FILE](state, fileId) {
state.currentDiffFileId = fileId;
@@ -369,31 +327,15 @@ export default {
renderFile(file);
}
},
- [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
- const file = state.diffFiles.find(f => f.file_path === filePath);
- const hiddenDiffLinesKey =
- state.diffViewType === 'inline' ? 'parallel_diff_lines' : 'highlighted_diff_lines';
-
- file[hiddenDiffLinesKey] = lines;
- },
[types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
const file = state.diffFiles.find(f => f.file_path === filePath);
- let currentDiffLinesKey;
- if (window.gon?.features?.unifiedDiffLines || state.diffViewType === 'inline') {
- currentDiffLinesKey = 'highlighted_diff_lines';
- } else {
- currentDiffLinesKey = 'parallel_diff_lines';
- }
-
- file[currentDiffLinesKey] = lines;
+ file[INLINE_DIFF_LINES_KEY] = lines;
},
[types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, line }) {
const file = state.diffFiles.find(f => f.file_path === filePath);
- const currentDiffLinesKey =
- state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines';
- file[currentDiffLinesKey].push(line);
+ file[INLINE_DIFF_LINES_KEY].push(line);
},
[types.TOGGLE_DIFF_FILE_RENDERING_MORE](state, filePath) {
const file = state.diffFiles.find(f => f.file_path === filePath);
@@ -408,4 +350,7 @@ export default {
[types.SET_SHOW_SUGGEST_POPOVER](state) {
state.showSuggestPopover = false;
},
+ [types.SET_FILE_BY_FILE](state, fileByFile) {
+ state.viewDiffsFileByFile = fileByFile;
+ },
};
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index f87f57c32c3..1839df12c96 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -12,12 +12,11 @@ import {
MATCH_LINE_TYPE,
LINES_TO_BE_RENDERED_DIRECTLY,
TREE_TYPE,
- INLINE_DIFF_VIEW_TYPE,
- PARALLEL_DIFF_VIEW_TYPE,
+ INLINE_DIFF_LINES_KEY,
SHOW_WHITESPACE,
NO_SHOW_WHITESPACE,
} from '../constants';
-import { prepareRawDiffFile } from '../diff_file';
+import { prepareRawDiffFile } from '../utils/diff_file';
export const isAdded = line => ['new', 'new-nonewline'].includes(line.type);
export const isRemoved = line => ['old', 'old-nonewline'].includes(line.type);
@@ -48,7 +47,7 @@ export const parallelizeDiffLines = (diffLines, inline) => {
for (let i = 0, diffLinesLength = diffLines.length, index = 0; i < diffLinesLength; i += 1) {
const line = diffLines[i];
- if (isRemoved(line)) {
+ if (isRemoved(line) || inline) {
lines.push({
[LINE_POSITION_LEFT]: line,
[LINE_POSITION_RIGHT]: null,
@@ -60,7 +59,7 @@ export const parallelizeDiffLines = (diffLines, inline) => {
}
index += 1;
} else if (isAdded(line)) {
- if (freeRightIndex !== null && !inline) {
+ if (freeRightIndex !== null) {
// If an old line came before this without a line on the right, this
// line can be put to the right of it.
lines[freeRightIndex].right = line;
@@ -178,43 +177,16 @@ export const findIndexInInlineLines = (lines, lineNumbers) => {
);
};
-export const findIndexInParallelLines = (lines, lineNumbers) => {
- const { oldLineNumber, newLineNumber } = lineNumbers;
-
- return lines.findIndex(
- line =>
- line.left &&
- line.right &&
- line.left.old_line === oldLineNumber &&
- line.right.new_line === newLineNumber,
- );
-};
-
-const indexGettersByViewType = {
- [INLINE_DIFF_VIEW_TYPE]: findIndexInInlineLines,
- [PARALLEL_DIFF_VIEW_TYPE]: findIndexInParallelLines,
-};
-
export const getPreviousLineIndex = (diffViewType, file, lineNumbers) => {
- const findIndex = indexGettersByViewType[diffViewType];
- const lines = {
- [INLINE_DIFF_VIEW_TYPE]: file.highlighted_diff_lines,
- [PARALLEL_DIFF_VIEW_TYPE]: file.parallel_diff_lines,
- };
-
- return findIndex && findIndex(lines[diffViewType], lineNumbers);
+ return findIndexInInlineLines(file[INLINE_DIFF_LINES_KEY], lineNumbers);
};
export function removeMatchLine(diffFile, lineNumbers, bottom) {
- const indexForInline = findIndexInInlineLines(diffFile.highlighted_diff_lines, lineNumbers);
- const indexForParallel = findIndexInParallelLines(diffFile.parallel_diff_lines, lineNumbers);
+ const indexForInline = findIndexInInlineLines(diffFile[INLINE_DIFF_LINES_KEY], lineNumbers);
const factor = bottom ? 1 : -1;
if (indexForInline > -1) {
- diffFile.highlighted_diff_lines.splice(indexForInline + factor, 1);
- }
- if (indexForParallel > -1) {
- diffFile.parallel_diff_lines.splice(indexForParallel + factor, 1);
+ diffFile[INLINE_DIFF_LINES_KEY].splice(indexForInline + factor, 1);
}
}
@@ -257,24 +229,6 @@ export function addLineReferences(lines, lineNumbers, bottom, isExpandDown, next
return linesWithNumbers;
}
-function addParallelContextLines(options) {
- const { parallelLines, contextLines, lineNumbers, isExpandDown } = options;
- const normalizedParallelLines = contextLines.map(line => ({
- left: line,
- right: line,
- line_code: line.line_code,
- }));
- const factor = isExpandDown ? 1 : 0;
-
- if (!isExpandDown && options.bottom) {
- parallelLines.push(...normalizedParallelLines);
- } else {
- const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers);
-
- parallelLines.splice(parallelIndex + factor, 0, ...normalizedParallelLines);
- }
-}
-
function addInlineContextLines(options) {
const { inlineLines, contextLines, lineNumbers, isExpandDown } = options;
const factor = isExpandDown ? 1 : 0;
@@ -289,16 +243,7 @@ function addInlineContextLines(options) {
}
export function addContextLines(options) {
- const { diffViewType } = options;
- const contextLineHandlers = {
- [INLINE_DIFF_VIEW_TYPE]: addInlineContextLines,
- [PARALLEL_DIFF_VIEW_TYPE]: addParallelContextLines,
- };
- const contextLineHandler = contextLineHandlers[diffViewType];
-
- if (contextLineHandler) {
- contextLineHandler(options);
- }
+ addInlineContextLines(options);
}
/**
@@ -324,41 +269,29 @@ export function trimFirstCharOfLineContent(line = {}) {
return parsedLine;
}
-function getLineCode({ left, right }, index) {
- if (left && left.line_code) {
- return left.line_code;
- } else if (right && right.line_code) {
- return right.line_code;
- }
- return index;
-}
-
function diffFileUniqueId(file) {
return `${file.content_sha}-${file.file_hash}`;
}
function mergeTwoFiles(target, source) {
- const originalInline = target.highlighted_diff_lines;
- const originalParallel = target.parallel_diff_lines;
+ const originalInline = target[INLINE_DIFF_LINES_KEY];
const missingInline = !originalInline.length;
- const missingParallel = !originalParallel.length;
return {
...target,
- highlighted_diff_lines: missingInline ? source.highlighted_diff_lines : originalInline,
- parallel_diff_lines: missingParallel ? source.parallel_diff_lines : originalParallel,
+ [INLINE_DIFF_LINES_KEY]: missingInline ? source[INLINE_DIFF_LINES_KEY] : originalInline,
+ parallel_diff_lines: null,
renderIt: source.renderIt,
collapsed: source.collapsed,
};
}
function ensureBasicDiffFileLines(file) {
- const missingInline = !file.highlighted_diff_lines;
- const missingParallel = !file.parallel_diff_lines || window.gon?.features?.unifiedDiffLines;
+ const missingInline = !file[INLINE_DIFF_LINES_KEY];
Object.assign(file, {
- highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines,
- parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines,
+ [INLINE_DIFF_LINES_KEY]: missingInline ? [] : file[INLINE_DIFF_LINES_KEY],
+ parallel_diff_lines: null,
});
return file;
@@ -382,7 +315,7 @@ function prepareLine(line, file) {
}
}
-export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index = 0 }) {
+export function prepareLineForRenamedFile({ line, diffFile, index = 0 }) {
/*
Renamed files are a little different than other diffs, which
is why this is distinct from `prepareDiffFileLines` below.
@@ -407,48 +340,23 @@ export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index
prepareLine(cleanLine, diffFile); // WARNING: In-Place Mutations!
- if (diffViewType === PARALLEL_DIFF_VIEW_TYPE) {
- return {
- left: { ...cleanLine },
- right: { ...cleanLine },
- line_code: cleanLine.line_code,
- };
- }
-
return cleanLine;
}
function prepareDiffFileLines(file) {
- const inlineLines = file.highlighted_diff_lines;
- const parallelLines = file.parallel_diff_lines;
- let parallelLinesCount = 0;
+ const inlineLines = file[INLINE_DIFF_LINES_KEY];
inlineLines.forEach(line => prepareLine(line, file)); // WARNING: In-Place Mutations!
- parallelLines.forEach((line, index) => {
- Object.assign(line, { line_code: getLineCode(line, index) });
-
- if (line.left) {
- parallelLinesCount += 1;
- prepareLine(line.left, file); // WARNING: In-Place Mutations!
- }
-
- if (line.right) {
- parallelLinesCount += 1;
- prepareLine(line.right, file); // WARNING: In-Place Mutations!
- }
- });
-
Object.assign(file, {
inlineLinesCount: inlineLines.length,
- parallelLinesCount,
});
return file;
}
function getVisibleDiffLines(file) {
- return Math.max(file.inlineLinesCount, file.parallelLinesCount);
+ return file.inlineLinesCount;
}
function finalizeDiffFile(file) {
@@ -478,9 +386,9 @@ function deduplicateFilesList(files) {
return Object.values(dedupedFiles);
}
-export function prepareDiffData(diff, priorFiles = []) {
+export function prepareDiffData({ diff, priorFiles = [], meta = false }) {
const cleanedFiles = (diff.diff_files || [])
- .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles }))
+ .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles, meta }))
.map(ensureBasicDiffFileLines)
.map(prepareDiffFileLines)
.map(finalizeDiffFile);
@@ -490,43 +398,14 @@ export function prepareDiffData(diff, priorFiles = []) {
export function getDiffPositionByLineCode(diffFiles) {
let lines = [];
- const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0);
-
- if (hasInlineDiffs) {
- // In either of these cases, we can use `highlighted_diff_lines` because
- // that will include all of the parallel diff lines, too
-
- lines = diffFiles.reduce((acc, diffFile) => {
- diffFile.highlighted_diff_lines.forEach(line => {
- acc.push({ file: diffFile, line });
- });
-
- return acc;
- }, []);
- } else {
- // If we're in single diff view mode and the inline lines haven't been
- // loaded yet, we need to parse the parallel lines
-
- lines = diffFiles.reduce((acc, diffFile) => {
- diffFile.parallel_diff_lines.forEach(pair => {
- // It's possible for a parallel line to have an opposite line that doesn't exist
- // For example: *deleted* lines will have `null` right lines, while
- // *added* lines will have `null` left lines.
- // So we have to check each line before we push it onto the array so we're not
- // pushing null line diffs
-
- if (pair.left) {
- acc.push({ file: diffFile, line: pair.left });
- }
- if (pair.right) {
- acc.push({ file: diffFile, line: pair.right });
- }
- });
+ lines = diffFiles.reduce((acc, diffFile) => {
+ diffFile[INLINE_DIFF_LINES_KEY].forEach(line => {
+ acc.push({ file: diffFile, line });
+ });
- return acc;
- }, []);
- }
+ return acc;
+ }, []);
return lines.reduce((acc, { file, line }) => {
if (line.line_code) {
@@ -739,24 +618,10 @@ export const convertExpandLines = ({
export const idleCallback = cb => requestIdleCallback(cb);
function getLinesFromFileByLineCode(file, lineCode) {
- const parallelLines = file.parallel_diff_lines;
- const inlineLines = file.highlighted_diff_lines;
+ const inlineLines = file[INLINE_DIFF_LINES_KEY];
const matchesCode = line => line.line_code === lineCode;
- return [
- ...parallelLines.reduce((acc, line) => {
- if (line.left) {
- acc.push(line.left);
- }
-
- if (line.right) {
- acc.push(line.right);
- }
-
- return acc;
- }, []),
- ...inlineLines,
- ].filter(matchesCode);
+ return inlineLines.filter(matchesCode);
}
export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
@@ -771,12 +636,7 @@ export const allDiscussionWrappersExpanded = diff => {
}
};
- diff.parallel_diff_lines.forEach(line => {
- changeExpandedResult(line.left);
- changeExpandedResult(line.right);
- });
-
- diff.highlighted_diff_lines.forEach(line => {
+ diff[INLINE_DIFF_LINES_KEY].forEach(line => {
changeExpandedResult(line);
});
diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/utils/diff_file.js
index a14a30b41a9..69d0e49e501 100644
--- a/app/assets/javascripts/diffs/diff_file.js
+++ b/app/assets/javascripts/diffs/utils/diff_file.js
@@ -3,7 +3,8 @@ import {
DIFF_FILE_DELETED_MODE,
DIFF_FILE_MANUAL_COLLAPSE,
DIFF_FILE_AUTOMATIC_COLLAPSE,
-} from './constants';
+} from '../constants';
+import { uuids } from './uuids';
function fileSymlinkInformation(file, fileList) {
const duplicates = fileList.filter(iteratedFile => iteratedFile.file_hash === file.file_hash);
@@ -32,16 +33,29 @@ function collapsed(file) {
};
}
-export function prepareRawDiffFile({ file, allFiles }) {
- Object.assign(file, {
+function identifier(file) {
+ return uuids({
+ seeds: [file.file_identifier_hash, file.blob?.id],
+ })[0];
+}
+
+export function prepareRawDiffFile({ file, allFiles, meta = false }) {
+ const additionalProperties = {
brokenSymlink: fileSymlinkInformation(file, allFiles),
viewer: {
...file.viewer,
...collapsed(file),
},
- });
+ };
+
+ // It's possible, but not confirmed, that `content_sha` isn't available sometimes
+ // See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49506#note_464692057
+ // We don't want duplicate IDs if that's the case, so we just don't assign an ID
+ if (!meta && file.blob?.id) {
+ additionalProperties.id = identifier(file);
+ }
- return file;
+ return Object.assign(file, additionalProperties);
}
export function collapsedType(file) {
diff --git a/app/assets/javascripts/diffs/utils/preferences.js b/app/assets/javascripts/diffs/utils/preferences.js
new file mode 100644
index 00000000000..e440de3350a
--- /dev/null
+++ b/app/assets/javascripts/diffs/utils/preferences.js
@@ -0,0 +1,22 @@
+import Cookies from 'js-cookie';
+import { getParameterValues } from '~/lib/utils/url_utility';
+
+import { DIFF_FILE_BY_FILE_COOKIE_NAME, DIFF_VIEW_FILE_BY_FILE } from '../constants';
+
+export function fileByFile(pref = false) {
+ const search = getParameterValues(DIFF_FILE_BY_FILE_COOKIE_NAME)?.[0];
+ const cookie = Cookies.get(DIFF_FILE_BY_FILE_COOKIE_NAME);
+ let viewFileByFile = pref;
+
+ // use the cookie first, if it exists
+ if (cookie) {
+ viewFileByFile = cookie === DIFF_VIEW_FILE_BY_FILE;
+ }
+
+ // the search parameter of the URL should override, if it exists
+ if (search) {
+ viewFileByFile = search === DIFF_VIEW_FILE_BY_FILE;
+ }
+
+ return viewFileByFile;
+}
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index 5674cc8495d..ffb5232ca75 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -119,20 +119,18 @@ class DueDateSelect {
}
updateIssueBoardIssue() {
- // eslint-disable-next-line no-jquery/no-fade
- this.$loading.fadeIn();
+ this.$loading.removeClass('gl-display-none');
this.$dropdown.trigger('loading.gl.dropdown');
this.$selectbox.hide();
this.$value.css('display', '');
- const fadeOutLoader = () => {
- // eslint-disable-next-line no-jquery/no-fade
- this.$loading.fadeOut();
+ const hideLoader = () => {
+ this.$loading.addClass('gl-display-none');
};
boardsStore.detail.issue
.update(this.$dropdown.attr('data-issue-update'))
- .then(fadeOutLoader)
- .catch(fadeOutLoader);
+ .then(hideLoader)
+ .catch(hideLoader);
}
submitSelectedDate(isDropdown) {
@@ -140,8 +138,7 @@ class DueDateSelect {
const hasDueDate = this.displayedDate !== __('None');
const displayedDateStyle = hasDueDate ? 'bold' : 'no-value';
- // eslint-disable-next-line no-jquery/no-fade
- this.$loading.removeClass('hidden').fadeIn();
+ this.$loading.removeClass('gl-display-none');
if (isDropdown) {
this.$dropdown.trigger('loading.gl.dropdown');
@@ -164,8 +161,7 @@ class DueDateSelect {
}
this.$sidebarCollapsedValue.attr('data-original-title', tooltipText);
- // eslint-disable-next-line no-jquery/no-fade
- return this.$loading.fadeOut();
+ return this.$loading.addClass('gl-display-none');
});
}
}
@@ -211,7 +207,8 @@ export default class DueDateSelectors {
initIssuableSelect() {
const $loading = $('.js-issuable-update .due_date')
.find('.block-loading')
- .hide();
+ .removeClass('hidden')
+ .addClass('gl-display-none');
$('.js-due-date-select').each((i, dropdown) => {
const $dropdown = $(dropdown);
diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js
index b02eb37206a..d6f87872bde 100644
--- a/app/assets/javascripts/editor/constants.js
+++ b/app/assets/javascripts/editor/constants.js
@@ -6,3 +6,7 @@ export const EDITOR_LITE_INSTANCE_ERROR_NO_EL = __(
export const URI_PREFIX = 'gitlab';
export const CONTENT_UPDATE_DEBOUNCE = 250;
+
+export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __(
+ 'Editor Lite instance is required to set up an extension.',
+);
diff --git a/app/assets/javascripts/editor/editor_file_template_ext.js b/app/assets/javascripts/editor/editor_file_template_ext.js
index 343908b831d..f5474318447 100644
--- a/app/assets/javascripts/editor/editor_file_template_ext.js
+++ b/app/assets/javascripts/editor/editor_file_template_ext.js
@@ -1,7 +1,8 @@
import { Position } from 'monaco-editor';
+import { EditorLiteExtension } from './editor_lite_extension_base';
-export default {
+export class FileTemplateExtension extends EditorLiteExtension {
navigateFileStart() {
this.setPosition(new Position(1, 1));
- },
-};
+ }
+}
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
index e7535c211db..2bd1cdc84d0 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -8,7 +8,7 @@ import { clearDomElement } from './utils';
import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from './constants';
import { uuids } from '~/diffs/utils/uuids';
-export default class Editor {
+export default class EditorLite {
constructor(options = {}) {
this.instances = [];
this.options = {
@@ -17,7 +17,7 @@ export default class Editor {
...options,
};
- Editor.setupMonacoTheme();
+ EditorLite.setupMonacoTheme();
registerLanguages(...languages);
}
@@ -54,12 +54,25 @@ export default class Editor {
extensionsArray.forEach(ext => {
const prefix = ext.includes('/') ? '' : 'editor/';
const trimmedExt = ext.replace(/^\//, '').trim();
- Editor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
+ EditorLite.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
});
return Promise.all(promises);
}
+ static mixIntoInstance(source, inst) {
+ if (!inst) {
+ return;
+ }
+ const isClassInstance = source.constructor.prototype !== Object.prototype;
+ const sanitizedSource = isClassInstance ? source.constructor.prototype : source;
+ Object.getOwnPropertyNames(sanitizedSource).forEach(prop => {
+ if (prop !== 'constructor') {
+ Object.assign(inst, { [prop]: source[prop] });
+ }
+ });
+ }
+
/**
* Creates a monaco instance with the given options.
*
@@ -101,10 +114,10 @@ export default class Editor {
this.instances.splice(index, 1);
model.dispose();
});
- instance.updateModelLanguage = path => Editor.updateModelLanguage(path, instance);
+ instance.updateModelLanguage = path => EditorLite.updateModelLanguage(path, instance);
instance.use = args => this.use(args, instance);
- Editor.loadExtensions(extensions, instance)
+ EditorLite.loadExtensions(extensions, instance)
.then(modules => {
if (modules) {
modules.forEach(module => {
@@ -129,10 +142,17 @@ export default class Editor {
use(exts = [], instance = null) {
const extensions = Array.isArray(exts) ? exts : [exts];
+ const initExtensions = inst => {
+ extensions.forEach(extension => {
+ EditorLite.mixIntoInstance(extension, inst);
+ });
+ };
if (instance) {
- Object.assign(instance, ...extensions);
+ initExtensions(instance);
} else {
- this.instances.forEach(inst => Object.assign(inst, ...extensions));
+ this.instances.forEach(inst => {
+ initExtensions(inst);
+ });
}
}
}
diff --git a/app/assets/javascripts/editor/editor_lite_extension_base.js b/app/assets/javascripts/editor/editor_lite_extension_base.js
new file mode 100644
index 00000000000..b8d87fa4969
--- /dev/null
+++ b/app/assets/javascripts/editor/editor_lite_extension_base.js
@@ -0,0 +1,11 @@
+import { ERROR_INSTANCE_REQUIRED_FOR_EXTENSION } from './constants';
+
+export class EditorLiteExtension {
+ constructor({ instance, ...options } = {}) {
+ if (instance) {
+ Object.assign(instance, options);
+ } else if (Object.entries(options).length) {
+ throw new Error(ERROR_INSTANCE_REQUIRED_FOR_EXTENSION);
+ }
+ }
+}
diff --git a/app/assets/javascripts/editor/editor_markdown_ext.js b/app/assets/javascripts/editor/editor_markdown_ext.js
index c46f5736912..19e0037c175 100644
--- a/app/assets/javascripts/editor/editor_markdown_ext.js
+++ b/app/assets/javascripts/editor/editor_markdown_ext.js
@@ -1,4 +1,6 @@
-export default {
+import { EditorLiteExtension } from './editor_lite_extension_base';
+
+export class EditorMarkdownExtension extends EditorLiteExtension {
getSelectedText(selection = this.getSelection()) {
const { startLineNumber, endLineNumber, startColumn, endColumn } = selection;
const valArray = this.getValue().split('\n');
@@ -18,19 +20,19 @@ export default {
: [startLineText, endLineText].join('\n');
}
return text;
- },
+ }
replaceSelectedText(text, select = undefined) {
const forceMoveMarkers = !select;
this.executeEdits('', [{ range: this.getSelection(), text, forceMoveMarkers }]);
- },
+ }
moveCursor(dx = 0, dy = 0) {
const pos = this.getPosition();
pos.column += dx;
pos.lineNumber += dy;
this.setPosition(pos);
- },
+ }
/**
* Adjust existing selection to select text within the original selection.
@@ -91,5 +93,5 @@ export default {
.setEndPosition(newEndLineNumber, newEndColumn);
this.setSelection(newSelection);
- },
-};
+ }
+}
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index bc35a07fe4a..2192d456861 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import eventHub from '../event_hub';
@@ -9,7 +9,8 @@ export default {
GlTooltip: GlTooltipDirective,
},
components: {
- GlButton,
+ GlDropdown,
+ GlDropdownItem,
GlIcon,
GlLoadingIcon,
},
@@ -35,7 +36,7 @@ export default {
if (action.scheduledAt) {
const confirmationMessage = sprintf(
s__(
- "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.",
+ 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.',
),
{ jobName: action.name },
);
@@ -67,40 +68,32 @@ export default {
};
</script>
<template>
- <div class="btn-group" role="group">
- <gl-button
- v-gl-tooltip
- :title="title"
- :aria-label="title"
- :disabled="isLoading"
- class="dropdown dropdown-new js-environment-actions-dropdown"
- data-container="body"
- data-toggle="dropdown"
- data-testid="environment-actions-button"
+ <gl-dropdown
+ v-gl-tooltip
+ :title="title"
+ :aria-label="title"
+ :disabled="isLoading"
+ right
+ data-container="body"
+ data-testid="environment-actions-button"
+ >
+ <template #button-content>
+ <gl-icon name="play" />
+ <gl-icon name="chevron-down" />
+ <gl-loading-icon v-if="isLoading" />
+ </template>
+ <gl-dropdown-item
+ v-for="(action, i) in actions"
+ :key="i"
+ :disabled="isActionDisabled(action)"
+ data-testid="manual-action-link"
+ @click="onClickAction(action)"
>
- <span>
- <gl-icon name="play" />
- <gl-icon name="chevron-down" />
- <gl-loading-icon v-if="isLoading" />
+ <span class="gl-flex-fill-1">{{ action.name }}</span>
+ <span v-if="action.scheduledAt" class="gl-text-gray-500 float-right">
+ <gl-icon name="clock" />
+ {{ remainingTime(action) }}
</span>
- </gl-button>
-
- <ul class="dropdown-menu dropdown-menu-right">
- <li v-for="(action, i) in actions" :key="i" class="gl-display-flex">
- <gl-button
- :class="{ disabled: isActionDisabled(action) }"
- :disabled="isActionDisabled(action)"
- variant="link"
- class="js-manual-action-link gl-flex-fill-1"
- @click="onClickAction(action)"
- >
- <span class="gl-flex-fill-1">{{ action.name }}</span>
- <span v-if="action.scheduledAt" class="text-secondary float-right">
- <gl-icon name="clock" />
- {{ remainingTime(action) }}
- </span>
- </gl-button>
- </li>
- </ul>
- </div>
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 48e81b168ec..347828888dc 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,13 +1,14 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { isEmpty } from 'lodash';
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import { GlTooltipDirective, GlIcon, GlLink } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
@@ -30,6 +31,7 @@ export default {
CommitComponent,
ExternalUrlComponent,
GlIcon,
+ GlLink,
MonitoringButtonComponent,
PinComponent,
DeleteComponent,
@@ -38,6 +40,7 @@ export default {
TerminalButtonComponent,
TooltipOnTruncate,
UserAvatarLink,
+ CiIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -81,6 +84,24 @@ export default {
},
/**
+ * @returns {Object|Undefined} The `upcoming_deployment` object if it exists.
+ * Otherwise, `undefined`.
+ */
+ upcomingDeployment() {
+ return this.model?.upcoming_deployment;
+ },
+
+ /**
+ * @returns {String} Text that will be shown in the tooltip when
+ * the user hovers over the upcoming deployment's status icon.
+ */
+ upcomingDeploymentTooltipText() {
+ return sprintf(s__('Environments|Deployment %{status}'), {
+ status: this.upcomingDeployment.deployable.status.text,
+ });
+ },
+
+ /**
* Checkes whether the row displayed is a folder.
*
* @returns {Boolean}
@@ -235,6 +256,18 @@ export default {
},
/**
+ * Same as `userImageAltDescription`, but for the
+ * upcoming deployment's user
+ *
+ * @returns {String}
+ */
+ upcomingDeploymentUserImageAltDescription() {
+ return sprintf(__("%{username}'s avatar"), {
+ username: this.upcomingDeployment.user.username,
+ });
+ },
+
+ /**
* If provided, returns the commit tag.
*
* @returns {String|Undefined}
@@ -382,6 +415,15 @@ export default {
},
/**
+ * Same as `deploymentInternalId`, but for the upcoming deployment
+ *
+ * @returns {String}
+ */
+ upcomingDeploymentInternalId() {
+ return `#${this.upcomingDeployment.iid}`;
+ },
+
+ /**
* Verifies if the user object is present under last_deployment object.
*
* @returns {Boolean}
@@ -503,6 +545,13 @@ export default {
folderIconName() {
return this.model.isOpen ? 'chevron-down' : 'chevron-right';
},
+
+ upcomingDeploymentCellClasses() {
+ return [
+ this.tableData.upcoming.spacing,
+ { 'gl-display-none gl-display-md-block': !this.upcomingDeployment },
+ ];
+ },
},
methods: {
@@ -512,6 +561,19 @@ export default {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model);
},
+
+ /**
+ * Returns the field title that will be shown in the field's row
+ * in the mobile view.
+ *
+ * @returns `field.mobileTitle` if present;
+ * if not, falls back to `field.title`.
+ */
+ getMobileViewTitleForField(fieldName) {
+ const field = this.tableData[fieldName];
+
+ return field.mobileTitle || field.title;
+ },
},
};
</script>
@@ -530,7 +592,7 @@ export default {
role="gridcell"
>
<div v-if="!isFolder" class="table-mobile-header" role="rowheader">
- {{ tableData.name.title }}
+ {{ getMobileViewTitleForField('name') }}
</div>
<span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard">
@@ -609,7 +671,9 @@ export default {
</div>
<div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell">
- <div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div>
+ <div role="rowheader" class="table-mobile-header">
+ {{ getMobileViewTitleForField('commit') }}
+ </div>
<div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
@@ -623,7 +687,9 @@ export default {
</div>
<div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell">
- <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
+ <div role="rowheader" class="table-mobile-header">
+ {{ getMobileViewTitleForField('date') }}
+ </div>
<span
v-if="canShowDeploymentDate"
v-gl-tooltip
@@ -636,8 +702,51 @@ export default {
</span>
</div>
+ <div
+ v-if="!isFolder"
+ class="table-section"
+ :class="upcomingDeploymentCellClasses"
+ role="gridcell"
+ data-testid="upcoming-deployment"
+ >
+ <div role="rowheader" class="table-mobile-header">
+ {{ getMobileViewTitleForField('upcoming') }}
+ </div>
+ <div
+ v-if="upcomingDeployment"
+ class="gl-w-full gl-display-flex gl-flex-direction-row gl-md-flex-direction-column! gl-justify-content-end"
+ data-testid="upcoming-deployment-content"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <span class="gl-mr-2">{{ upcomingDeploymentInternalId }}</span>
+ <gl-link
+ v-if="upcomingDeployment.deployable"
+ v-gl-tooltip
+ :href="upcomingDeployment.deployable.build_path"
+ :title="upcomingDeploymentTooltipText"
+ data-testid="upcoming-deployment-status-link"
+ >
+ <ci-icon class="gl-mr-2" :status="upcomingDeployment.deployable.status" />
+ </gl-link>
+ </div>
+ <div class="gl-display-flex">
+ <span v-if="upcomingDeployment.user" class="text-break-word">
+ by
+ <user-avatar-link
+ :link-href="upcomingDeployment.user.web_url"
+ :img-src="upcomingDeployment.user.avatar_url"
+ :img-alt="upcomingDeploymentUserImageAltDescription"
+ :tooltip-text="upcomingDeployment.user.username"
+ />
+ </span>
+ </div>
+ </div>
+ </div>
+
<div v-if="!isFolder" class="table-section" :class="tableData.autoStop.spacing" role="gridcell">
- <div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div>
+ <div role="rowheader" class="table-mobile-header">
+ {{ getMobileViewTitleForField('autoStop') }}
+ </div>
<span
v-if="canShowAutoStopDate"
v-gl-tooltip
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index c1b9ba755a6..b6a7cce36e9 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -93,7 +93,9 @@ export default {
},
beforeDestroy() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('toggleFolder');
+ // eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('toggleDeployBoard');
},
@@ -141,13 +143,7 @@ export default {
<confirm-rollback-modal :environment="environmentInRollbackModal" />
<div class="gl-w-full">
- <div
- class="
- gl-display-flex
- gl-flex-direction-column
- gl-mt-3
- gl-display-md-none!"
- >
+ <div class="gl-display-flex gl-flex-direction-column gl-mt-3 gl-display-md-none!">
<gl-button
v-if="state.reviewAppDetails.can_setup_review_app"
v-gl-modal="$options.modal.id"
@@ -156,18 +152,16 @@ export default {
category="secondary"
type="button"
class="gl-mb-3 gl-flex-fill-1"
+ >{{ $options.i18n.reviewAppButtonLabel }}</gl-button
>
- {{ $options.i18n.reviewAppButtonLabel }}
- </gl-button>
<gl-button
v-if="canCreateEnvironment"
:href="newEnvironmentPath"
data-testid="new-environment"
category="primary"
variant="success"
+ >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
>
- {{ $options.i18n.newEnvironmentButtonLabel }}
- </gl-button>
</div>
<gl-tabs content-class="gl-display-none">
<gl-tab
@@ -183,14 +177,7 @@ export default {
</gl-tab>
<template #tabs-end>
<div
- class="
- gl-display-none
- gl-display-md-flex
- gl-lg-align-items-center
- gl-lg-flex-direction-row
- gl-lg-flex-fill-1
- gl-lg-justify-content-end
- gl-lg-mt-0"
+ class="gl-display-none gl-display-md-flex gl-lg-align-items-center gl-lg-flex-direction-row gl-lg-flex-fill-1 gl-lg-justify-content-end gl-lg-mt-0"
>
<gl-button
v-if="state.reviewAppDetails.can_setup_review_app"
@@ -200,18 +187,16 @@ export default {
category="secondary"
type="button"
class="gl-mb-3 gl-lg-mr-3 gl-lg-mb-0"
+ >{{ $options.i18n.reviewAppButtonLabel }}</gl-button
>
- {{ $options.i18n.reviewAppButtonLabel }}
- </gl-button>
<gl-button
v-if="canCreateEnvironment"
:href="newEnvironmentPath"
data-testid="new-environment"
category="primary"
variant="success"
+ >{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
>
- {{ $options.i18n.newEnvironmentButtonLabel }}
- </gl-button>
</div>
</template>
</gl-tabs>
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index c1b3eabec16..d13c7204285 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -15,6 +15,7 @@ export default {
CanaryDeploymentCallout: () =>
import('ee_component/environments/components/canary_deployment_callout.vue'),
EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
+ CanaryUpdateModal: () => import('ee_component/environments/components/canary_update_modal.vue'),
},
props: {
environments: {
@@ -58,6 +59,12 @@ export default {
default: '',
},
},
+ data() {
+ return {
+ canaryWeight: 0,
+ environmentToChange: null,
+ };
+ },
computed: {
sortedEnvironments() {
return this.sortEnvironments(this.environments).map(env =>
@@ -71,7 +78,7 @@ export default {
// percent spacing for cols, should add up to 100
name: {
title: s__('Environments|Environment'),
- spacing: 'section-15',
+ spacing: 'section-10',
},
deploy: {
title: s__('Environments|Deployment'),
@@ -83,18 +90,23 @@ export default {
},
commit: {
title: s__('Environments|Commit'),
- spacing: 'section-20',
+ spacing: 'section-15',
},
date: {
title: s__('Environments|Updated'),
spacing: 'section-10',
},
+ upcoming: {
+ title: s__('Environments|Upcoming'),
+ mobileTitle: s__('Environments|Upcoming deployment'),
+ spacing: 'section-10',
+ },
autoStop: {
title: s__('Environments|Auto stop in'),
- spacing: 'section-5',
+ spacing: 'section-10',
},
actions: {
- spacing: 'section-25',
+ spacing: 'section-20',
},
};
},
@@ -139,11 +151,16 @@ export default {
sortBy(env => (env.isFolder ? -1 : 1)),
)(environments);
},
+ changeCanaryWeight(model, weight) {
+ this.environmentToChange = model;
+ this.canaryWeight = weight;
+ },
},
};
</script>
<template>
<div class="ci-table" role="grid">
+ <canary-update-modal :environment="environmentToChange" :weight="canaryWeight" />
<div class="gl-responsive-table-row table-row-header" role="row">
<div class="table-section" :class="tableData.name.spacing" role="columnheader">
{{ tableData.name.title }}
@@ -160,6 +177,9 @@ export default {
<div class="table-section" :class="tableData.date.spacing" role="columnheader">
{{ tableData.date.title }}
</div>
+ <div class="table-section" :class="tableData.upcoming.spacing" role="columnheader">
+ {{ tableData.upcoming.title }}
+ </div>
<div class="table-section" :class="tableData.autoStop.spacing" role="columnheader">
{{ tableData.autoStop.title }}
</div>
@@ -171,6 +191,7 @@ export default {
:model="model"
:can-read-environment="canReadEnvironment"
:table-data="tableData"
+ data-qa-selector="environment_item"
/>
<div
@@ -185,6 +206,7 @@ export default {
:is-loading="model.isLoadingDeployBoard"
:is-empty="model.isEmptyDeployBoard"
:logs-path="model.logs_path"
+ @changeCanaryWeight="changeCanaryWeight(model, $event)"
/>
</div>
</div>
@@ -207,6 +229,7 @@ export default {
:model="children"
:can-read-environment="canReadEnvironment"
:table-data="tableData"
+ data-qa-selector="environment_item"
/>
<div :key="`sub-div-${i}`">
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index cd4bb476b6e..c3471346a63 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -80,7 +80,7 @@ export default {
<div ref="header" class="file-title file-title-flex-parent">
<div class="file-header-content d-flex align-content-center">
<div v-if="hasCode" class="d-inline-block cursor-pointer" @click="toggle()">
- <gl-icon :name="collapseIcon" :size="16" aria-hidden="true" class="gl-mr-2" />
+ <gl-icon :name="collapseIcon" :size="16" class="gl-mr-2" />
</div>
<file-icon :file-name="filePath" :size="18" aria-hidden="true" css-classes="gl-mr-2" />
<strong
diff --git a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
index bf47d7cf7c0..5953a4fbad8 100644
--- a/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
+++ b/app/assets/javascripts/feature_flags/components/configure_feature_flags_modal.vue
@@ -9,10 +9,10 @@ import {
GlSprintf,
GlLink,
GlIcon,
+ GlAlert,
} from '@gitlab/ui';
import { s__, __ } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
-import Callout from '~/vue_shared/components/callout.vue';
export default {
components: {
@@ -22,10 +22,10 @@ export default {
GlModal,
ModalCopyButton,
GlIcon,
- Callout,
GlLoadingIcon,
GlSprintf,
GlLink,
+ GlAlert,
},
directives: {
@@ -153,8 +153,7 @@ export default {
</template>
</gl-sprintf>
</p>
-
- <callout category="warning">
+ <gl-alert variant="warning" class="gl-mb-5" :dismissible="false">
<gl-sprintf
:message="
s__(
@@ -168,7 +167,7 @@ export default {
}}</gl-link>
</template>
</gl-sprintf>
- </callout>
+ </gl-alert>
<gl-form-group :label="$options.translations.apiUrlLabelText" label-for="api-url">
<gl-form-input-group id="api-url" :value="unleashApiUrl" readonly type="text" name="api-url">
<template #append>
@@ -212,11 +211,9 @@ export default {
<gl-icon name="warning" class="gl-mr-2" />
<span>{{ $options.translations.instanceIdRegenerateError }}</span>
</div>
- <callout
- v-if="canUserRotateToken"
- category="danger"
- :message="$options.translations.instanceIdRegenerateText"
- />
+ <gl-alert v-if="canUserRotateToken" variant="danger" class="gl-mb-5" :dismissible="false">
+ {{ $options.translations.instanceIdRegenerateText }}
+ </gl-alert>
<p v-if="canUserRotateToken" data-testid="prevent-accident-text">
<gl-sprintf
:message="
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index 9ec65bb0b43..b89e9723606 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -4,7 +4,7 @@ import { mapState, mapActions } from 'vuex';
import axios from '~/lib/utils/axios_utils';
import { sprintf, s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { LEGACY_FLAG, NEW_FLAG_ALERT } from '../constants';
+import { LEGACY_FLAG } from '../constants';
import FeatureFlagForm from './form.vue';
export default {
@@ -36,7 +36,6 @@ export default {
legacyReadOnlyFlagAlert: s__(
'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.',
),
- newFlagAlert: NEW_FLAG_ALERT,
},
computed: {
...mapState([
@@ -58,7 +57,7 @@ export default {
: sprintf(s__('Edit %{name}'), { name: this.name });
},
deprecated() {
- return this.hasNewVersionFlags && this.version === LEGACY_FLAG;
+ return this.version === LEGACY_FLAG;
},
deprecatedAndEditable() {
return this.deprecated && !this.hasLegacyReadOnlyFlags;
@@ -66,18 +65,12 @@ export default {
deprecatedAndReadOnly() {
return this.deprecated && this.hasLegacyReadOnlyFlags;
},
- hasNewVersionFlags() {
- return this.glFeatures.featureFlagsNewVersion;
- },
hasLegacyReadOnlyFlags() {
return (
this.glFeatures.featureFlagsLegacyReadOnly &&
!this.glFeatures.featureFlagsLegacyReadOnlyOverride
);
},
- shouldShowNewFlagAlert() {
- return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
- },
},
created() {
return this.fetchFeatureFlag();
@@ -95,14 +88,6 @@ export default {
</script>
<template>
<div>
- <gl-alert
- v-if="shouldShowNewFlagAlert"
- variant="warning"
- class="gl-my-5"
- @dismiss="dismissNewVersionFlagAlert"
- >
- {{ $options.translations.newFlagAlert }}
- </gl-alert>
<gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-7" />
<template v-else-if="!isLoading && !hasError">
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue
index 340cf68793f..fe327a98605 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue
@@ -7,7 +7,6 @@ import { FEATURE_FLAG_SCOPE, USER_LIST_SCOPE } from '../constants';
import FeatureFlagsTab from './feature_flags_tab.vue';
import FeatureFlagsTable from './feature_flags_table.vue';
import UserListsTable from './user_lists_table.vue';
-import { s__ } from '~/locale';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import {
buildUrlWithCurrentLocation,
@@ -96,9 +95,6 @@ export default {
hasNewPath() {
return !isEmpty(this.newFeatureFlagPath);
},
- emptyStateTitle() {
- return s__('FeatureFlags|Get started with feature flags');
- },
},
created() {
this.setFeatureFlagsOptions({ scope: this.scope, page: this.page });
@@ -246,7 +242,12 @@ export default {
:error-state="shouldRenderErrorState"
:error-title="s__(`FeatureFlags|There was an error fetching the feature flags.`)"
:empty-state="shouldShowEmptyState"
- :empty-title="emptyStateTitle"
+ :empty-title="s__('FeatureFlags|Get started with feature flags')"
+ :empty-description="
+ s__(
+ 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
+ )
+ "
data-testid="feature-flags-tab"
@dismissAlert="clearAlert"
@changeTab="onFeatureFlagsTab"
@@ -266,7 +267,12 @@ export default {
:error-state="shouldRenderErrorState"
:error-title="s__(`FeatureFlags|There was an error fetching the user lists.`)"
:empty-state="shouldShowEmptyState"
- :empty-title="emptyStateTitle"
+ :empty-title="s__('FeatureFlags|Get started with user lists')"
+ :empty-description="
+ s__(
+ 'FeatureFlags|User lists allow you to define a set of users to use with Feature Flags.',
+ )
+ "
data-testid="user-lists-tab"
@dismissAlert="clearAlert"
@changeTab="onUserListsTab"
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
index 5c35aa33e14..0539b5ff832 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_tab.vue
@@ -41,6 +41,10 @@ export default {
required: true,
type: String,
},
+ emptyDescription: {
+ required: true,
+ type: String,
+ },
},
inject: ['errorStateSvgPath', 'featureFlagsHelpPagePath'],
computed: {
@@ -92,11 +96,7 @@ export default {
data-testid="empty-state"
>
<template #description>
- {{
- s__(
- 'FeatureFlags|Feature flags allow you to configure your code into different flavors by dynamically toggling certain functionality.',
- )
- }}
+ {{ emptyDescription }}
<gl-link :href="featureFlagsHelpPagePath" target="_blank">
{{ s__('FeatureFlags|More information') }}
</gl-link>
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 54d038606f4..ba46bab2df0 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -38,9 +38,6 @@ export default {
permissions() {
return this.glFeatures.featureFlagPermissions;
},
- isNewVersionFlagsEnabled() {
- return this.glFeatures.featureFlagsNewVersion;
- },
isLegacyReadOnlyFlagsEnabled() {
return (
this.glFeatures.featureFlagsLegacyReadOnly &&
@@ -68,7 +65,7 @@ export default {
},
methods: {
isLegacyFlag(flag) {
- return !this.isNewVersionFlagsEnabled || flag.version !== NEW_VERSION_FLAG;
+ return flag.version !== NEW_VERSION_FLAG;
},
statusToggleDisabled(flag) {
return this.isLegacyReadOnlyFlagsEnabled && flag.version === LEGACY_FLAG;
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index 36ebf893486..12856b79f63 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -137,14 +137,13 @@ export default {
return this.glFeatures.featureFlagPermissions;
},
supportsStrategies() {
- return this.glFeatures.featureFlagsNewVersion && this.version === NEW_VERSION_FLAG;
+ return this.version === NEW_VERSION_FLAG;
},
showRelatedIssues() {
return this.featureFlagIssuesEndpoint.length > 0;
},
readOnly() {
return (
- this.glFeatures.featureFlagsNewVersion &&
this.glFeatures.featureFlagsLegacyReadOnly &&
!this.glFeatures.featureFlagsLegacyReadOnlyOverride &&
this.version === LEGACY_FLAG
diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
index 9472eddf336..e6949d8028b 100644
--- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
@@ -1,21 +1,14 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlAlert } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import FeatureFlagForm from './form.vue';
-import {
- LEGACY_FLAG,
- NEW_VERSION_FLAG,
- NEW_FLAG_ALERT,
- ROLLOUT_STRATEGY_ALL_USERS,
-} from '../constants';
+import { NEW_VERSION_FLAG, ROLLOUT_STRATEGY_ALL_USERS } from '../constants';
import { createNewEnvironmentScope } from '../store/helpers';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
- GlAlert,
FeatureFlagForm,
},
mixins: [featureFlagsMixin()],
@@ -33,9 +26,6 @@ export default {
userShouldSeeNewFlagAlert: this.showUserCallout,
};
},
- translations: {
- newFlagAlert: NEW_FLAG_ALERT,
- },
computed: {
...mapState(['error', 'path']),
scopes() {
@@ -50,13 +40,7 @@ export default {
];
},
version() {
- return this.hasNewVersionFlags ? NEW_VERSION_FLAG : LEGACY_FLAG;
- },
- hasNewVersionFlags() {
- return this.glFeatures.featureFlagsNewVersion;
- },
- shouldShowNewFlagAlert() {
- return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
+ return NEW_VERSION_FLAG;
},
strategies() {
return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }];
@@ -75,14 +59,6 @@ export default {
</script>
<template>
<div>
- <gl-alert
- v-if="shouldShowNewFlagAlert"
- variant="warning"
- class="gl-my-5"
- @dismiss="dismissNewVersionFlagAlert"
- >
- {{ $options.translations.newFlagAlert }}
- </gl-alert>
<h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3>
<div v-if="error.length" class="alert alert-danger">
diff --git a/app/assets/javascripts/feature_flags/components/strategy.vue b/app/assets/javascripts/feature_flags/components/strategy.vue
index 9c41dde62e4..ce03248381c 100644
--- a/app/assets/javascripts/feature_flags/components/strategy.vue
+++ b/app/assets/javascripts/feature_flags/components/strategy.vue
@@ -183,11 +183,11 @@ export default {
<span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3">
{{ $options.i18n.allEnvironments }}
</span>
- <div v-else class="gl-display-flex gl-align-items-center">
+ <div v-else class="gl-display-flex gl-align-items-center gl-flex-wrap">
<gl-token
v-for="environment in filteredEnvironments"
:key="environment.id"
- class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill"
+ class="gl-mt-3 gl-mr-3 gl-mb-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill"
@close="removeScope(environment)"
>
{{ environment.environmentScope }}
diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js
index 4843eca149a..658984456a5 100644
--- a/app/assets/javascripts/feature_flags/constants.js
+++ b/app/assets/javascripts/feature_flags/constants.js
@@ -21,10 +21,6 @@ export const fetchUserIdParams = property(['parameters', 'userIds']);
export const NEW_VERSION_FLAG = 'new_version_flag';
export const LEGACY_FLAG = 'legacy_flag';
-export const NEW_FLAG_ALERT = s__(
- 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.',
-);
-
export const FEATURE_FLAG_SCOPE = 'featureFlags';
export const USER_LIST_SCOPE = 'userLists';
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 4aad54bed55..eabf3b0846e 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -64,8 +64,7 @@ export default class FilterableList {
return false;
}
- // eslint-disable-next-line no-jquery/no-fade
- $(this.listHolderElement).fadeTo(250, 0.5);
+ $(this.listHolderElement).addClass('gl-opacity-5');
this.isBusy = true;
@@ -99,7 +98,6 @@ export default class FilterableList {
onFilterComplete() {
this.isBusy = false;
- // eslint-disable-next-line no-jquery/no-fade
- $(this.listHolderElement).fadeTo(250, 1);
+ $(this.listHolderElement).removeClass('gl-opacity-5');
}
}
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index 7d4df25816b..38a5bdd4a71 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -1,6 +1,18 @@
import { __ } from '~/locale';
export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
+ const reviewerToken = {
+ formattedKey: __('Reviewer'),
+ key: 'reviewer',
+ type: 'string',
+ param: 'username',
+ symbol: '@',
+ icon: 'user',
+ tag: '@reviewer',
+ };
+ IssuableTokenKeys.tokenKeys.splice(2, 0, reviewerToken);
+ IssuableTokenKeys.tokenKeysWithAlternative.splice(2, 0, reviewerToken);
+
const draftToken = {
token: {
formattedKey: __('Draft'),
@@ -91,7 +103,7 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
],
};
- const tokenPosition = 2;
+ const tokenPosition = 3;
IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]);
IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]);
IssuableTokenKeys.conditions.push(...approvedBy.condition);
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index d7645f96406..77491d1556b 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -71,6 +71,11 @@ export default class AvailableDropdownMappings {
gl: DropdownUser,
element: this.container.querySelector('#js-dropdown-assignee'),
},
+ reviewer: {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.querySelector('#js-dropdown-reviewer'),
+ },
'approved-by': {
reference: null,
gl: DropdownUser,
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index 6cd6f9c9906..08736b09407 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -1,4 +1,4 @@
-export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by'];
+export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer'];
export const DROPDOWN_TYPE = {
hint: 'hint',
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index d2ac80fa190..f9388e9c5d8 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -86,6 +86,16 @@ export const conditions = flattenDeep(
value: __('Any'),
},
{
+ url: 'reviewer_id=None',
+ tokenKey: 'reviewer',
+ value: __('None'),
+ },
+ {
+ url: 'reviewer_id=Any',
+ tokenKey: 'reviewer',
+ value: __('Any'),
+ },
+ {
url: 'author_username=support-bot',
tokenKey: 'author',
value: 'support-bot',
diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
index 7e9b809e9b2..54d49821d92 100644
--- a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
+++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
@@ -1,4 +1,6 @@
export default {
issues: 'issue-recent-searches',
merge_requests: 'merge-request-recent-searches',
+ group_members: 'group-members-recent-searches',
+ group_invited_members: 'group-invited-members-recent-searches',
};
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index 61080fb5487..c4f61b839e4 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -3,7 +3,6 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import AccessorUtilities from '~/lib/utils/accessor';
import eventHub from '../event_hub';
-import store from '../store';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils';
import FrequentItemsSearchInput from './frequent_items_search_input.vue';
@@ -11,7 +10,6 @@ import FrequentItemsList from './frequent_items_list.vue';
import frequentItemsMixin from './frequent_items_mixin';
export default {
- store,
components: {
FrequentItemsSearchInput,
FrequentItemsList,
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 1203f389931..3260d768fd9 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,13 +1,18 @@
<script>
/* eslint-disable vue/require-default-prop, vue/no-v-html */
+import { mapState } from 'vuex';
import Identicon from '~/vue_shared/components/identicon.vue';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
+import Tracking from '~/tracking';
+
+const trackingMixin = Tracking.mixin();
export default {
components: {
Identicon,
},
+ mixins: [trackingMixin],
props: {
matcher: {
type: String,
@@ -37,6 +42,7 @@ export default {
},
},
computed: {
+ ...mapState(['dropdownType']),
truncatedNamespace() {
return truncateNamespace(this.namespace);
},
@@ -49,7 +55,11 @@ export default {
<template>
<li class="frequent-items-list-item-container">
- <a :href="webUrl" class="clearfix">
+ <a
+ :href="webUrl"
+ class="clearfix"
+ @click="track('click_link', { label: `${dropdownType}_dropdown_frequent_items_list_item` })"
+ >
<div
ref="frequentItemsItemAvatarContainer"
class="frequent-items-item-avatar-container avatar-container rect-avatar s32"
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
index 19cb09f0dcc..8042e8c7bc9 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue
@@ -1,27 +1,34 @@
<script>
import { debounce } from 'lodash';
-import { mapActions } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { GlIcon } from '@gitlab/ui';
import eventHub from '../event_hub';
import frequentItemsMixin from './frequent_items_mixin';
+import Tracking from '~/tracking';
+
+const trackingMixin = Tracking.mixin();
export default {
components: {
GlIcon,
},
- mixins: [frequentItemsMixin],
+ mixins: [frequentItemsMixin, trackingMixin],
data() {
return {
searchQuery: '',
};
},
computed: {
+ ...mapState(['dropdownType']),
translations() {
return this.getTranslations(['searchInputPlaceholder']);
},
},
watch: {
searchQuery: debounce(function debounceSearchQuery() {
+ this.track('type_search_query', {
+ label: `${this.dropdownType}_dropdown_frequent_items_search_input`,
+ });
this.setSearchQuery(this.searchQuery);
}, 500),
},
diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js
index 1998bf4358a..639562bf961 100644
--- a/app/assets/javascripts/frequent_items/index.js
+++ b/app/assets/javascripts/frequent_items/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import eventHub from './event_hub';
+import { createStore } from '~/frequent_items/store';
Vue.use(Translate);
@@ -28,11 +29,15 @@ export default function initFrequentItemDropdowns() {
return;
}
+ const dropdownType = namespace;
+ const store = createStore({ dropdownType });
+
import('./components/app.vue')
.then(({ default: FrequentItems }) => {
// eslint-disable-next-line no-new
new Vue({
el,
+ store,
data() {
const { dataset } = this.$options.el;
const item = {
diff --git a/app/assets/javascripts/frequent_items/store/index.js b/app/assets/javascripts/frequent_items/store/index.js
index ece9e6419dd..83176d69802 100644
--- a/app/assets/javascripts/frequent_items/store/index.js
+++ b/app/assets/javascripts/frequent_items/store/index.js
@@ -7,10 +7,11 @@ import state from './state';
Vue.use(Vuex);
-export default () =>
- new Vuex.Store({
+export const createStore = (initState = {}) => {
+ return new Vuex.Store({
actions,
getters,
mutations,
- state: state(),
+ state: state(initState),
});
+};
diff --git a/app/assets/javascripts/frequent_items/store/state.js b/app/assets/javascripts/frequent_items/store/state.js
index 75b04febee4..c5c0b25fdf2 100644
--- a/app/assets/javascripts/frequent_items/store/state.js
+++ b/app/assets/javascripts/frequent_items/store/state.js
@@ -1,5 +1,6 @@
-export default () => ({
+export default ({ dropdownType = '' } = {}) => ({
namespace: '',
+ dropdownType,
storageKey: '',
searchQuery: '',
isLoadingItems: false,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 14538ad7237..dcb27434a07 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -78,6 +78,7 @@ class GfmAutoComplete {
this.input.each((i, input) => {
const $input = $(input);
if (!$input.hasClass('js-gfm-input-initialized')) {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
$input.on('change.atwho', () => input.dispatchEvent(new Event('input')));
// This triggers at.js again
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index ac4c8d28ee4..60f1b7f5aa4 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -80,6 +80,7 @@ export default class GlFieldError {
// hidden when injected into DOM
errorAnchor.after(this.fieldErrorElement);
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.inputElement.off('invalid').on('invalid', this.handleInvalidSubmit.bind(this));
this.scopedSiblings = this.safelySelectSiblings();
}
@@ -117,6 +118,7 @@ export default class GlFieldError {
this.form.focusInvalid.apply(this.form);
// For UX, wait til after first invalid submission to check each keyup
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.inputElement
.off('keyup.fieldValidator')
.on('keyup.fieldValidator', this.updateValidity.bind(this));
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 6958cf4c173..4a3755f39cc 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -70,8 +70,10 @@ export default class GLForm {
}
setupAutosize() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.textarea.off('autosize:resized').on('autosize:resized', this.setHeightData.bind(this));
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.textarea.off('mouseup.autosize').on('mouseup.autosize', this.destroyAutosize.bind(this));
setTimeout(() => {
@@ -97,7 +99,9 @@ export default class GLForm {
}
clearEventListeners() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.textarea.off('focus');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.textarea.off('blur');
removeMarkdownListeners(this.form);
}
diff --git a/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
new file mode 100644
index 00000000000..b64ceb8e2c9
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/queries/users_search.query.graphql
@@ -0,0 +1,9 @@
+#import "../fragments/user.fragment.graphql"
+
+query usersSearch($search: String!) {
+ users(search: $search) {
+ nodes {
+ ...User
+ }
+ }
+}
diff --git a/app/assets/javascripts/graphql_shared/utils.js b/app/assets/javascripts/graphql_shared/utils.js
index 5487aeb9391..813e21b6ce9 100644
--- a/app/assets/javascripts/graphql_shared/utils.js
+++ b/app/assets/javascripts/graphql_shared/utils.js
@@ -14,3 +14,41 @@ export const MutationOperationMode = {
Remove: 'REMOVE',
Replace: 'REPLACE',
};
+
+/**
+ * Possible GraphQL entity types.
+ */
+export const TYPE_GROUP = 'Group';
+
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Groups/123. This method takes a type and an id
+ * and interpolates the 2 values into the expected GraphQL ID format.
+ *
+ * @param {String} type The entity type
+ * @param {String|Number} id The id value
+ * @returns {String}
+ */
+export const convertToGraphQLId = (type, id) => {
+ if (typeof type !== 'string') {
+ throw new TypeError(`type must be a string; got ${typeof type}`);
+ }
+
+ if (!['number', 'string'].includes(typeof id)) {
+ throw new TypeError(`id must be a number or string; got ${typeof id}`);
+ }
+
+ return `gid://gitlab/${type}/${id}`;
+};
+
+/**
+ * Ids generated by GraphQL endpoints are usually in the format
+ * gid://gitlab/Groups/123. This method takes a type and an
+ * array of ids and tranforms the array values into the expected
+ * GraphQL ID format.
+ *
+ * @param {String} type The entity type
+ * @param {Array} ids An array of id values
+ * @returns {Array}
+ */
+export const convertToGraphQLIds = (type, ids) => ids.map(id => convertToGraphQLId(type, id));
diff --git a/app/assets/javascripts/groups/components/group_folder.vue b/app/assets/javascripts/groups/components/group_folder.vue
index d2a613bed4f..5f169832ee4 100644
--- a/app/assets/javascripts/groups/components/group_folder.vue
+++ b/app/assets/javascripts/groups/components/group_folder.vue
@@ -49,7 +49,7 @@ export default {
/>
<li v-if="hasMoreChildren" class="group-row">
<a :href="parentGroup.relativePath" class="group-row-contents has-more-items py-2">
- <gl-icon name="external-link" aria-hidden="true" /> {{ moreChildrenStats }}
+ <gl-icon name="external-link" /> {{ moreChildrenStats }}
</a>
</li>
</ul>
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 6e99b6ad4fa..ef58b93c049 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -74,6 +74,9 @@ export default {
visibilityTooltip() {
return GROUP_VISIBILITY_TYPE[this.group.visibility];
},
+ microdata() {
+ return this.group.microdata || {};
+ },
},
mounted() {
if (this.group.name === 'Learn GitLab') {
@@ -99,7 +102,15 @@ export default {
</script>
<template>
- <li :id="groupDomId" :class="rowClass" class="group-row" @click.stop="onClickRowGroup">
+ <li
+ :id="groupDomId"
+ :class="rowClass"
+ class="group-row"
+ :itemprop="microdata.itemprop"
+ :itemtype="microdata.itemtype"
+ :itemscope="microdata.itemscope"
+ @click.stop="onClickRowGroup"
+ >
<div
:class="{ 'project-row-contents': !isGroup }"
class="group-row-contents d-flex align-items-center py-2 pr-3"
@@ -118,7 +129,13 @@ export default {
class="avatar-container rect-avatar s32 d-none flex-grow-0 flex-shrink-0 "
>
<a :href="group.relativePath" class="no-expand">
- <img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s40" />
+ <img
+ v-if="hasAvatar"
+ :src="group.avatarUrl"
+ data-testid="group-avatar"
+ class="avatar s40"
+ :itemprop="microdata.imageItemprop"
+ />
<identicon v-else :entity-id="group.id" :entity-name="group.name" size-class="s40" />
</a>
</div>
@@ -127,9 +144,11 @@ export default {
<div class="d-flex align-items-center flex-wrap title namespace-title gl-mr-3">
<a
v-gl-tooltip.bottom
+ data-testid="group-name"
:href="group.relativePath"
:title="group.fullName"
class="no-expand gl-mt-3 gl-mr-3 gl-text-gray-900!"
+ :itemprop="microdata.nameItemprop"
>{{
// ending bracket must be by closing tag to prevent
// link hover text-decoration from over-extending
@@ -146,7 +165,12 @@ export default {
</span>
</div>
<div v-if="group.description" class="description">
- <span v-html="group.description"> </span>
+ <span
+ :itemprop="microdata.descriptionItemprop"
+ data-testid="group-description"
+ v-html="group.description"
+ >
+ </span>
</div>
</div>
<div v-if="isGroupPendingRemoval">
diff --git a/app/assets/javascripts/groups/components/visibility_level_dropdown.vue b/app/assets/javascripts/groups/components/visibility_level_dropdown.vue
new file mode 100644
index 00000000000..ff0f8c3ff46
--- /dev/null
+++ b/app/assets/javascripts/groups/components/visibility_level_dropdown.vue
@@ -0,0 +1,48 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ visibilityLevelOptions: {
+ type: Array,
+ required: true,
+ },
+ defaultLevel: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedOption: this.getDefaultOption(),
+ };
+ },
+ methods: {
+ getDefaultOption() {
+ return this.visibilityLevelOptions.find(option => option.level === this.defaultLevel);
+ },
+ onClick(option) {
+ this.selectedOption = option;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <input type="hidden" name="group[visibility_level]" :value="selectedOption.level" />
+ <gl-dropdown :text="selectedOption.label" class="gl-w-full" menu-class="gl-w-full! gl-mb-0">
+ <gl-dropdown-item
+ v-for="option in visibilityLevelOptions"
+ :key="option.level"
+ :secondary-text="option.description"
+ @click="onClick(option)"
+ >
+ <div class="gl-font-weight-bold gl-mb-1">{{ option.label }}</div>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js
index 522f1d16df2..e11c3aaf984 100644
--- a/app/assets/javascripts/groups/index.js
+++ b/app/assets/javascripts/groups/index.js
@@ -47,8 +47,9 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
data() {
const { dataset } = dataEl || this.$options.el;
const hideProjects = parseBoolean(dataset.hideProjects);
+ const showSchemaMarkup = parseBoolean(dataset.showSchemaMarkup);
const service = new GroupsService(endpoint || dataset.endpoint);
- const store = new GroupsStore(hideProjects);
+ const store = new GroupsStore({ hideProjects, showSchemaMarkup });
return {
action,
diff --git a/app/assets/javascripts/groups/members/components/app.vue b/app/assets/javascripts/groups/members/components/app.vue
index 2e6dd4a0bad..f6f3a955813 100644
--- a/app/assets/javascripts/groups/members/components/app.vue
+++ b/app/assets/javascripts/groups/members/components/app.vue
@@ -1,13 +1,16 @@
<script>
import { mapState, mapMutations } from 'vuex';
import { GlAlert } from '@gitlab/ui';
-import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
+import MembersTable from '~/members/components/table/members_table.vue';
+import FilterSortContainer from '~/members/components/filter_sort/filter_sort_container.vue';
import { scrollToElement } from '~/lib/utils/common_utils';
-import { HIDE_ERROR } from '~/vuex_shared/modules/members/mutation_types';
+import { HIDE_ERROR } from '~/members/store/mutation_types';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'GroupMembersApp',
- components: { MembersTable, GlAlert },
+ components: { MembersTable, FilterSortContainer, GlAlert },
+ mixins: [glFeatureFlagsMixin()],
computed: {
...mapState(['showError', 'errorMessage']),
},
@@ -33,6 +36,7 @@ export default {
<gl-alert v-if="showError" ref="errorAlert" variant="danger" @dismiss="hideError">{{
errorMessage
}}</gl-alert>
+ <filter-sort-container v-if="glFeatures.groupMembersFilteredSearch" />
<members-table />
</div>
</template>
diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js
index cb28fb057c9..9ce0e3c1179 100644
--- a/app/assets/javascripts/groups/members/index.js
+++ b/app/assets/javascripts/groups/members/index.js
@@ -3,9 +3,18 @@ import Vuex from 'vuex';
import { GlToast } from '@gitlab/ui';
import { parseDataAttributes } from 'ee_else_ce/groups/members/utils';
import App from './components/app.vue';
-import membersModule from '~/vuex_shared/modules/members';
+import membersStore from '~/members/store';
-export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatter) => {
+export const initGroupMembersApp = (
+ el,
+ {
+ tableFields = [],
+ tableAttrs = {},
+ tableSortableFields = [],
+ requestFormatter = () => {},
+ filteredSearchBar = { show: false },
+ },
+) => {
if (!el) {
return () => {};
}
@@ -13,15 +22,17 @@ export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatte
Vue.use(Vuex);
Vue.use(GlToast);
- const store = new Vuex.Store({
- ...membersModule({
+ const store = new Vuex.Store(
+ membersStore({
...parseDataAttributes(el),
currentUserId: gon.current_user_id || null,
tableFields,
tableAttrs,
+ tableSortableFields,
requestFormatter,
+ filteredSearchBar,
}),
- });
+ );
return new Vue({
el,
diff --git a/app/assets/javascripts/groups/members/utils.js b/app/assets/javascripts/groups/members/utils.js
index 662eecc4e38..2d584556bbc 100644
--- a/app/assets/javascripts/groups/members/utils.js
+++ b/app/assets/javascripts/groups/members/utils.js
@@ -1,5 +1,5 @@
import { isUndefined } from 'lodash';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import {
GROUP_MEMBER_BASE_PROPERTY_NAME,
GROUP_MEMBER_ACCESS_LEVEL_PROPERTY_NAME,
@@ -8,12 +8,13 @@ import {
} from './constants';
export const parseDataAttributes = el => {
- const { members, groupId, memberPath } = el.dataset;
+ const { members, groupId, memberPath, canManageMembers } = el.dataset;
return {
members: convertObjectPropsToCamelCase(JSON.parse(members), { deep: true }),
sourceId: parseInt(groupId, 10),
memberPath,
+ canManageMembers: parseBoolean(canManageMembers),
};
};
diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js
index 6a1197fa163..b6cea38e87f 100644
--- a/app/assets/javascripts/groups/store/groups_store.js
+++ b/app/assets/javascripts/groups/store/groups_store.js
@@ -1,11 +1,13 @@
import { normalizeHeaders, parseIntPagination } from '../../lib/utils/common_utils';
+import { getGroupItemMicrodata } from './utils';
export default class GroupsStore {
- constructor(hideProjects) {
+ constructor({ hideProjects = false, showSchemaMarkup = false } = {}) {
this.state = {};
this.state.groups = [];
this.state.pageInfo = {};
this.hideProjects = hideProjects;
+ this.showSchemaMarkup = showSchemaMarkup;
}
setGroups(rawGroups) {
@@ -94,6 +96,7 @@ export default class GroupsStore {
starCount: rawGroupItem.star_count,
updatedAt: rawGroupItem.updated_at,
pendingRemoval: rawGroupItem.marked_for_deletion,
+ microdata: this.showSchemaMarkup ? getGroupItemMicrodata(rawGroupItem) : {},
};
}
diff --git a/app/assets/javascripts/groups/store/utils.js b/app/assets/javascripts/groups/store/utils.js
new file mode 100644
index 00000000000..371b3aa9d52
--- /dev/null
+++ b/app/assets/javascripts/groups/store/utils.js
@@ -0,0 +1,27 @@
+export const getGroupItemMicrodata = ({ type }) => {
+ const defaultMicrodata = {
+ itemscope: true,
+ itemtype: 'https://schema.org/Thing',
+ itemprop: 'owns',
+ imageItemprop: 'image',
+ nameItemprop: 'name',
+ descriptionItemprop: 'description',
+ };
+
+ switch (type) {
+ case 'group':
+ return {
+ ...defaultMicrodata,
+ itemtype: 'https://schema.org/Organization',
+ itemprop: 'subOrganization',
+ imageItemprop: 'logo',
+ };
+ case 'project':
+ return {
+ ...defaultMicrodata,
+ itemtype: 'https://schema.org/SoftwareSourceCode',
+ };
+ default:
+ return defaultMicrodata;
+ }
+};
diff --git a/app/assets/javascripts/groups/visibility_level.js b/app/assets/javascripts/groups/visibility_level.js
new file mode 100644
index 00000000000..d570b5e65ac
--- /dev/null
+++ b/app/assets/javascripts/groups/visibility_level.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import VisibilityLevelDropdown from './components/visibility_level_dropdown.vue';
+
+export default () => {
+ const el = document.querySelector('.js-visibility-level-dropdown');
+
+ if (!el) {
+ return null;
+ }
+
+ const { visibilityLevelOptions, defaultLevel } = el.dataset;
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(VisibilityLevelDropdown, {
+ props: {
+ visibilityLevelOptions: JSON.parse(visibilityLevelOptions),
+ defaultLevel: Number(defaultLevel),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index aac23db8fd6..29af8c77d25 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -4,97 +4,107 @@ import axios from './lib/utils/axios_utils';
import Api from './api';
import { normalizeHeaders } from './lib/utils/common_utils';
import { __ } from '~/locale';
+import { loadCSSFile } from './lib/utils/css_utils';
+
+const fetchGroups = params => {
+ axios[params.type.toLowerCase()](params.url, {
+ params: params.data,
+ })
+ .then(res => {
+ const results = res.data || [];
+ const headers = normalizeHeaders(res.headers);
+ const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
+ const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
+ const more = currentPage < totalPages;
+
+ params.success({
+ results,
+ pagination: {
+ more,
+ },
+ });
+ })
+ .catch(params.error);
+};
const groupsSelect = () => {
- // Needs to be accessible in rspec
- window.GROUP_SELECT_PER_PAGE = 20;
- $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
- const $select = $(this);
- const allAvailable = $select.data('allAvailable');
- const skipGroups = $select.data('skipGroups') || [];
- const parentGroupID = $select.data('parentId');
- const groupsPath = parentGroupID
- ? Api.subgroupsPath.replace(':id', parentGroupID)
- : Api.groupsPath;
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ // Needs to be accessible in rspec
+ window.GROUP_SELECT_PER_PAGE = 20;
- $select.select2({
- placeholder: __('Search for a group'),
- allowClear: $select.hasClass('allowClear'),
- multiple: $select.hasClass('multiselect'),
- minimumInputLength: 0,
- ajax: {
- url: Api.buildUrl(groupsPath),
- dataType: 'json',
- quietMillis: 250,
- transport(params) {
- axios[params.type.toLowerCase()](params.url, {
- params: params.data,
- })
- .then(res => {
- const results = res.data || [];
- const headers = normalizeHeaders(res.headers);
- const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
- const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
- const more = currentPage < totalPages;
+ $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
+ const $select = $(this);
+ const allAvailable = $select.data('allAvailable');
+ const skipGroups = $select.data('skipGroups') || [];
+ const parentGroupID = $select.data('parentId');
+ const groupsPath = parentGroupID
+ ? Api.subgroupsPath.replace(':id', parentGroupID)
+ : Api.groupsPath;
- params.success({
- results,
- pagination: {
- more,
- },
- });
- })
- .catch(params.error);
- },
- data(search, page) {
- return {
- search,
- page,
- per_page: window.GROUP_SELECT_PER_PAGE,
- all_available: allAvailable,
- };
- },
- results(data, page) {
- if (data.length) return { results: [] };
+ $select.select2({
+ placeholder: __('Search for a group'),
+ allowClear: $select.hasClass('allowClear'),
+ multiple: $select.hasClass('multiselect'),
+ minimumInputLength: 0,
+ ajax: {
+ url: Api.buildUrl(groupsPath),
+ dataType: 'json',
+ quietMillis: 250,
+ transport(params) {
+ fetchGroups(params);
+ },
+ data(search, page) {
+ return {
+ search,
+ page,
+ per_page: window.GROUP_SELECT_PER_PAGE,
+ all_available: allAvailable,
+ };
+ },
+ results(data, page) {
+ if (data.length) return { results: [] };
- const groups = data.length ? data : data.results || [];
- const more = data.pagination ? data.pagination.more : false;
- const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
+ const groups = data.length ? data : data.results || [];
+ const more = data.pagination ? data.pagination.more : false;
+ const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
- return {
- results,
- page,
- more,
- };
- },
- },
- // eslint-disable-next-line consistent-return
- initSelection(element, callback) {
- const id = $(element).val();
- if (id !== '') {
- return Api.group(id, callback);
- }
- },
- formatResult(object) {
- return `<div class='group-result'> <div class='group-name'>${escape(
- object.full_name,
- )}</div> <div class='group-path'>${object.full_path}</div> </div>`;
- },
- formatSelection(object) {
- return escape(object.full_name);
- },
- dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
- });
+ return {
+ results,
+ page,
+ more,
+ };
+ },
+ },
+ // eslint-disable-next-line consistent-return
+ initSelection(element, callback) {
+ const id = $(element).val();
+ if (id !== '') {
+ return Api.group(id, callback);
+ }
+ },
+ formatResult(object) {
+ return `<div class='group-result'> <div class='group-name'>${escape(
+ object.full_name,
+ )}</div> <div class='group-path'>${object.full_path}</div> </div>`;
+ },
+ formatSelection(object) {
+ return escape(object.full_name);
+ },
+ dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup(m) {
+ return m;
+ },
+ });
- $select.on('select2-loaded', () => {
- const dropdown = document.querySelector('.select2-infinite .select2-results');
- dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
- });
- });
+ $select.on('select2-loaded', () => {
+ const dropdown = document.querySelector('.select2-infinite .select2-results');
+ dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
+ });
+ });
+ })
+ .catch(() => {});
};
export default () => {
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 1cedb557d46..9f9708bf879 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import Vue from 'vue';
-import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
import { highCountTrim } from '~/lib/utils/text_utility';
import Tracking from '~/tracking';
@@ -35,7 +34,6 @@ function initStatusTriggers() {
const statusModalElement = document.createElement('div');
setStatusModalWrapperEl.appendChild(statusModalElement);
- Vue.use(GlToast);
Vue.use(Translate);
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/helpers/issuables_helper.js b/app/assets/javascripts/helpers/issuables_helper.js
deleted file mode 100644
index 52d0f7e43fc..00000000000
--- a/app/assets/javascripts/helpers/issuables_helper.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import CloseReopenReportToggle from '../close_reopen_report_toggle';
-
-function initCloseReopenReport() {
- const container = document.querySelector('.js-issuable-close-dropdown');
-
- if (!container) return undefined;
-
- const dropdownTrigger = container.querySelector('.js-issuable-close-toggle');
- const dropdownList = container.querySelector('.js-issuable-close-menu');
- const button = container.querySelector('.js-issuable-close-button');
-
- const closeReopenReportToggle = new CloseReopenReportToggle({
- dropdownTrigger,
- dropdownList,
- button,
- });
-
- closeReopenReportToggle.initDroplab();
-
- return closeReopenReportToggle;
-}
-
-const IssuablesHelper = {
- initCloseReopenReport,
-};
-
-export default IssuablesHelper;
diff --git a/app/assets/javascripts/how_to_merge.js b/app/assets/javascripts/how_to_merge.js
deleted file mode 100644
index bb734246584..00000000000
--- a/app/assets/javascripts/how_to_merge.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import $ from 'jquery';
-
-export default () => {
- const modal = $('#modal_merge_info');
-
- if (modal) {
- modal.modal({
- modal: true,
- show: false,
- });
-
- $('.how_to_merge_link').on('click', modal.show);
- $('.modal-header .close').on('click', modal.hide);
- }
-};
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index 9d2deb1d4d0..7c3e522a488 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -134,15 +134,17 @@ export default {
@after-enter="afterEndTransition"
>
<div v-if="isCompact" ref="compactEl" class="commit-form-compact">
- <button
+ <gl-button
:disabled="!someUncommittedChanges"
- type="button"
- class="btn btn-primary btn-sm btn-block qa-begin-commit-button"
+ category="primary"
+ variant="info"
+ block
+ class="qa-begin-commit-button"
data-testid="begin-commit-button"
@click="beginCommit"
>
{{ __('Commit…') }}
- </button>
+ </gl-button>
<p class="text-center bold">{{ overviewText }}</p>
</div>
<form v-else ref="formEl" @submit.prevent.stop="commit">
@@ -158,28 +160,21 @@ export default {
<gl-button
:loading="submitCommitLoading"
class="float-left qa-commit-button"
- size="small"
category="primary"
variant="success"
@click="commit"
>
{{ __('Commit') }}
</gl-button>
- <button
- v-if="!discardDraftButtonDisabled"
- type="button"
- class="btn btn-default btn-sm float-right"
- @click="discardDraft"
- >
+ <gl-button v-if="!discardDraftButtonDisabled" class="float-right" @click="discardDraft">
{{ __('Discard draft') }}
- </button>
+ </gl-button>
<gl-button
v-else
type="button"
class="float-right"
category="secondary"
variant="default"
- size="small"
@click="toggleIsCompact"
>
{{ __('Collapse') }}
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 7d08815b033..8f0e5aef456 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -1,13 +1,9 @@
<script>
import { GlIcon, GlPopover } from '@gitlab/ui';
import { __, sprintf } from '../../../locale';
-import popover from '../../../vue_shared/directives/popover';
import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants';
export default {
- directives: {
- popover,
- },
components: {
GlIcon,
GlPopover,
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
index dec8aa61838..52593aabfea 100644
--- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -1,11 +1,12 @@
<script>
-import { GlButton } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { viewerTypes } from '../constants';
export default {
components: {
- GlButton,
+ GlDropdown,
+ GlDropdownItem,
},
props: {
viewer: {
@@ -18,10 +19,21 @@ export default {
},
},
computed: {
- mergeReviewLine() {
- return sprintf(__('Reviewing (merge request !%{mergeRequestId})'), {
- mergeRequestId: this.mergeRequestId,
- });
+ modeDropdownItems() {
+ return [
+ {
+ viewerType: this.$options.viewerTypes.mr,
+ title: sprintf(__('Reviewing (merge request !%{mergeRequestId})'), {
+ mergeRequestId: this.mergeRequestId,
+ }),
+ content: __('Compare changes with the merge request target branch'),
+ },
+ {
+ viewerType: this.$options.viewerTypes.diff,
+ title: __('Reviewing'),
+ content: __('Compare changes with the last commit'),
+ },
+ ];
},
},
methods: {
@@ -34,39 +46,16 @@ export default {
</script>
<template>
- <div class="dropdown">
- <gl-button variant="link" data-toggle="dropdown">{{ __('Edit') }}</gl-button>
- <div class="dropdown-menu dropdown-menu-selectable dropdown-open-left">
- <ul>
- <li>
- <a
- :class="{
- 'is-active': viewer === $options.viewerTypes.mr,
- }"
- href="#"
- @click.prevent="changeMode($options.viewerTypes.mr)"
- >
- <strong class="dropdown-menu-inner-title"> {{ mergeReviewLine }} </strong>
- <span class="dropdown-menu-inner-content">
- {{ __('Compare changes with the merge request target branch') }}
- </span>
- </a>
- </li>
- <li>
- <a
- :class="{
- 'is-active': viewer === $options.viewerTypes.diff,
- }"
- href="#"
- @click.prevent="changeMode($options.viewerTypes.diff)"
- >
- <strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong>
- <span class="dropdown-menu-inner-content">
- {{ __('Compare changes with the last commit') }}
- </span>
- </a>
- </li>
- </ul>
- </div>
- </div>
+ <gl-dropdown :text="__('Edit')" size="small">
+ <gl-dropdown-item
+ v-for="mode in modeDropdownItems"
+ :key="mode.viewerType"
+ :is-check-item="true"
+ :is-checked="viewer === mode.viewerType"
+ @click="changeMode(mode.viewerType)"
+ >
+ <strong class="dropdown-menu-inner-title"> {{ mode.title }} </strong>
+ <span class="dropdown-menu-inner-content"> {{ mode.content }} </span>
+ </gl-dropdown-item>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
index cfd2555b769..5d5b66a6444 100644
--- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue
+++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
@@ -86,7 +86,7 @@ export default {
type="search"
class="dropdown-input-field qa-dropdown-filter-input"
/>
- <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" />
+ <gl-icon name="search" class="dropdown-input-search" />
</div>
<div class="dropdown-content">
<gl-loading-icon v-if="showLoading" size="lg" />
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index e1d2895831a..f8568f46cd6 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,14 +1,13 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import {
WEBIDE_MARK_APP_START,
WEBIDE_MARK_FILE_FINISH,
WEBIDE_MARK_FILE_CLICKED,
- WEBIDE_MARK_TREE_FINISH,
- WEBIDE_MEASURE_TREE_FROM_REQUEST,
- WEBIDE_MEASURE_FILE_FROM_REQUEST,
WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
+ WEBIDE_MEASURE_BEFORE_VUE,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { modalTypes } from '../constants';
@@ -19,12 +18,6 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { measurePerformance } from '../utils';
-eventHub.$on(WEBIDE_MEASURE_TREE_FROM_REQUEST, () =>
- measurePerformance(WEBIDE_MARK_TREE_FINISH, WEBIDE_MEASURE_TREE_FROM_REQUEST),
-);
-eventHub.$on(WEBIDE_MEASURE_FILE_FROM_REQUEST, () =>
- measurePerformance(WEBIDE_MARK_FILE_FINISH, WEBIDE_MEASURE_FILE_FROM_REQUEST),
-);
eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () =>
measurePerformance(
WEBIDE_MARK_FILE_FINISH,
@@ -37,15 +30,17 @@ export default {
components: {
IdeSidebar,
RepoEditor,
- 'error-message': () => import('./error_message.vue'),
- 'gl-button': () => import('@gitlab/ui/src/components/base/button/button.vue'),
- 'gl-loading-icon': () => import('@gitlab/ui/src/components/base/loading_icon/loading_icon.vue'),
- 'commit-editor-header': () => import('./commit_sidebar/editor_header.vue'),
- 'repo-tabs': () => import('./repo_tabs.vue'),
- 'ide-status-bar': () => import('./ide_status_bar.vue'),
- 'find-file': () => import('~/vue_shared/components/file_finder/index.vue'),
- 'right-pane': () => import('./panes/right.vue'),
- 'new-modal': () => import('./new_dropdown/modal.vue'),
+ GlButton,
+ GlLoadingIcon,
+ ErrorMessage: () => import(/* webpackChunkName: 'ide_runtime' */ './error_message.vue'),
+ CommitEditorHeader: () =>
+ import(/* webpackChunkName: 'ide_runtime' */ './commit_sidebar/editor_header.vue'),
+ RepoTabs: () => import(/* webpackChunkName: 'ide_runtime' */ './repo_tabs.vue'),
+ IdeStatusBar: () => import(/* webpackChunkName: 'ide_runtime' */ './ide_status_bar.vue'),
+ FindFile: () =>
+ 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'),
},
mixins: [glFeatureFlagsMixin()],
data() {
@@ -84,7 +79,14 @@ export default {
document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
},
beforeCreate() {
- performanceMarkAndMeasure({ mark: WEBIDE_MARK_APP_START });
+ performanceMarkAndMeasure({
+ mark: WEBIDE_MARK_APP_START,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_BEFORE_VUE,
+ },
+ ],
+ });
},
methods: {
...mapActions(['toggleFileFinder']),
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 99215d6c3f1..135b28685ed 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -14,8 +14,10 @@ export default {
ResizablePanel,
ActivityBar,
IdeTree,
- [leftSidebarViews.review.name]: () => import('./ide_review.vue'),
- [leftSidebarViews.commit.name]: () => import('./repo_commit_section.vue'),
+ [leftSidebarViews.review.name]: () =>
+ import(/* webpackChunkName: 'ide_runtime' */ './ide_review.vue'),
+ [leftSidebarViews.commit.name]: () =>
+ import(/* webpackChunkName: 'ide_runtime' */ './repo_commit_section.vue'),
CommitForm,
IdeProjectHeader,
},
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index e7e94f5b5da..b67881b14f4 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -2,17 +2,13 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import FileTree from '~/vue_shared/components/file_tree.vue';
-import {
- WEBIDE_MARK_TREE_START,
- WEBIDE_MEASURE_TREE_FROM_REQUEST,
- WEBIDE_MARK_FILE_CLICKED,
-} from '~/performance/constants';
+import { WEBIDE_MARK_FILE_CLICKED } from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
-import eventHub from '../eventhub';
import IdeFileRow from './ide_file_row.vue';
import NavDropdown from './nav_dropdown.vue';
export default {
+ name: 'IdeTreeList',
components: {
GlSkeletonLoading,
NavDropdown,
@@ -39,14 +35,6 @@ export default {
}
},
},
- beforeCreate() {
- performanceMarkAndMeasure({ mark: WEBIDE_MARK_TREE_START });
- },
- updated() {
- if (this.currentTree?.tree?.length) {
- eventHub.$emit(WEBIDE_MEASURE_TREE_FROM_REQUEST);
- }
- },
methods: {
...mapActions(['toggleTreeOpen']),
clickedFile() {
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index d65304034c2..7f07a5dbe43 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -92,7 +92,7 @@ export default {
class="controllers-buttons"
target="_blank"
>
- <gl-icon name="doc-text" aria-hidden="true" />
+ <gl-icon name="doc-text" />
</a>
<scroll-button :disabled="isScrolledToTop" direction="up" @click="scrollUp" />
<scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" />
diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue
index 2307efd1d24..8cea8655461 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown.vue
@@ -30,6 +30,7 @@ export default {
.on('hide.bs.dropdown', () => this.hideDropdown());
},
removeDropdownListeners() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$(this.$refs.dropdown)
.off('show.bs.dropdown')
.off('hide.bs.dropdown');
@@ -45,7 +46,7 @@ export default {
</script>
<template>
- <div ref="dropdown" class="btn-group ide-nav-dropdown dropdown">
+ <div ref="dropdown" class="btn-group ide-nav-dropdown dropdown" data-testid="ide-nav-dropdown">
<nav-dropdown-button :show-merge-requests="canReadMergeRequests" />
<div class="dropdown-menu dropdown-menu-left p-0">
<nav-form v-if="isVisibleDropdown" :show-merge-requests="canReadMergeRequests" />
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 5ad836f346a..22eefb6634f 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -137,6 +137,7 @@ export default {
ref="modal"
modal-id="ide-new-entry"
data-qa-selector="new_file_modal"
+ data-testid="ide-new-entry"
:title="modalTitle"
:ok-title="buttonLabel"
ok-variant="success"
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 6f15773c9ab..a4a13389fbf 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -8,6 +8,7 @@ import {
GlTabs,
GlTab,
GlBadge,
+ GlAlert,
} from '@gitlab/ui';
import { sprintf, __ } from '../../../locale';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
@@ -26,6 +27,7 @@ export default {
GlTabs,
GlTab,
GlBadge,
+ GlAlert,
},
directives: {
SafeHtml,
@@ -89,11 +91,16 @@ export default {
:can-set-ci="true"
class="mb-auto mt-auto"
/>
- <div v-else-if="latestPipeline.yamlError" class="bs-callout bs-callout-danger">
+ <gl-alert
+ v-else-if="latestPipeline.yamlError"
+ variant="danger"
+ :dismissible="false"
+ class="gl-mt-5"
+ >
<p class="gl-mb-0">{{ __('Found errors in your .gitlab-ci.yml:') }}</p>
<p class="gl-mb-0 break-word">{{ latestPipeline.yamlError }}</p>
<p v-safe-html="ciLintText" class="gl-mb-0"></p>
- </div>
+ </gl-alert>
<gl-tabs v-else>
<gl-tab :active="!pipelineFailed">
<template #title>
diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue
index 3852f2fdfa4..f65b1201d94 100644
--- a/app/assets/javascripts/ide/components/preview/clientside.vue
+++ b/app/assets/javascripts/ide/components/preview/clientside.vue
@@ -152,7 +152,7 @@ export default {
</script>
<template>
- <div class="preview h-100 w-100 d-flex flex-column">
+ <div class="preview h-100 w-100 d-flex flex-column gl-bg-white">
<template v-if="showPreview">
<navigator :manager="manager" />
<div id="ide-preview"></div>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index c8a825065f1..1f029612c29 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -6,9 +6,10 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import {
WEBIDE_MARK_FILE_CLICKED,
- WEBIDE_MARK_FILE_START,
+ WEBIDE_MARK_REPO_EDITOR_START,
+ WEBIDE_MARK_REPO_EDITOR_FINISH,
+ WEBIDE_MEASURE_REPO_EDITOR,
WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
- WEBIDE_MEASURE_FILE_FROM_REQUEST,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import eventHub from '../eventhub';
@@ -28,6 +29,7 @@ import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
export default {
+ name: 'RepoEditor',
components: {
ContentViewer,
DiffViewer,
@@ -175,9 +177,6 @@ export default {
}
},
},
- beforeCreate() {
- performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_START });
- },
beforeDestroy() {
this.editor.dispose();
},
@@ -204,6 +203,7 @@ export default {
]),
...mapActions('editor', ['updateFileEditor']),
initEditor() {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_REPO_EDITOR_START });
if (this.shouldHideEditor && (this.file.content || this.file.raw)) {
return;
}
@@ -305,7 +305,15 @@ export default {
if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
} else {
- eventHub.$emit(WEBIDE_MEASURE_FILE_FROM_REQUEST);
+ performanceMarkAndMeasure({
+ mark: WEBIDE_MARK_REPO_EDITOR_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_REPO_EDITOR,
+ start: WEBIDE_MARK_REPO_EDITOR_START,
+ },
+ ],
+ });
}
},
refreshEditorDimensions() {
diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue
index a8fe9ea6866..0e67a2ab45f 100644
--- a/app/assets/javascripts/ide/components/terminal/session.vue
+++ b/app/assets/javascripts/ide/components/terminal/session.vue
@@ -1,5 +1,6 @@
<script>
import { mapActions, mapState } from 'vuex';
+import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import Terminal from './terminal.vue';
import { isEndingStatus } from '../../stores/modules/terminal/utils';
@@ -7,6 +8,7 @@ import { isEndingStatus } from '../../stores/modules/terminal/utils';
export default {
components: {
Terminal,
+ GlButton,
},
computed: {
...mapState('terminal', ['session']),
@@ -14,15 +16,17 @@ export default {
if (isEndingStatus(this.session.status)) {
return {
action: () => this.restartSession(),
+ variant: 'info',
+ category: 'primary',
text: __('Restart Terminal'),
- class: 'btn-primary',
};
}
return {
action: () => this.stopSession(),
+ variant: 'danger',
+ category: 'secondary',
text: __('Stop Terminal'),
- class: 'btn-inverted btn-remove',
};
},
},
@@ -37,15 +41,13 @@ export default {
<header class="ide-job-header d-flex align-items-center">
<h5>{{ __('Web Terminal') }}</h5>
<div class="ml-auto align-self-center">
- <button
+ <gl-button
v-if="actionButton"
- type="button"
- class="btn btn-sm"
- :class="actionButton.class"
+ :variant="actionButton.variant"
+ :category="actionButton.category"
@click="actionButton.action"
+ >{{ actionButton.text }}</gl-button
>
- {{ actionButton.text }}
- </button>
</div>
</header>
<terminal :terminal-path="session.terminalPath" :status="session.status" />
diff --git a/app/assets/javascripts/ide/components/terminal/view.vue b/app/assets/javascripts/ide/components/terminal/view.vue
index db97e95eed9..fcf23eb1f73 100644
--- a/app/assets/javascripts/ide/components/terminal/view.vue
+++ b/app/assets/javascripts/ide/components/terminal/view.vue
@@ -1,12 +1,11 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import EmptyState from './empty_state.vue';
-import TerminalSession from './session.vue';
export default {
components: {
EmptyState,
- TerminalSession,
+ TerminalSession: () => import(/* webpackChunkName: 'ide_terminal' */ './session.vue'),
},
computed: {
...mapState('terminal', ['isShowSplash', 'paths']),
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 396aedbfa10..b9ebacef7e1 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -3,6 +3,12 @@ import IdeRouter from '~/ide/ide_router_extension';
import { joinPaths } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as flash } from '~/flash';
import { __ } from '~/locale';
+import { performanceMarkAndMeasure } from '~/performance/utils';
+import {
+ WEBIDE_MARK_FETCH_PROJECT_DATA_START,
+ WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH,
+ WEBIDE_MEASURE_FETCH_PROJECT_DATA,
+} from '~/performance/constants';
import { syncRouterAndStore } from './sync_router_and_store';
Vue.use(IdeRouter);
@@ -69,6 +75,7 @@ export const createRouter = store => {
router.beforeEach((to, from, next) => {
if (to.params.namespace && to.params.project) {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START });
store
.dispatch('getProjectData', {
namespace: to.params.namespace,
@@ -81,6 +88,15 @@ export const createRouter = store => {
const mergeRequestId = to.params.mrid;
if (branchId) {
+ performanceMarkAndMeasure({
+ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_FETCH_PROJECT_DATA,
+ start: WEBIDE_MARK_FETCH_PROJECT_DATA_START,
+ },
+ ],
+ });
store.dispatch('openBranch', {
projectId,
branchId,
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 56d48e87c18..62f49ba56b1 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import { mapActions } from 'vuex';
import { identity } from 'lodash';
import Translate from '~/vue_shared/translate';
+import PerformancePlugin from '~/performance/vue_performance_plugin';
import ide from './components/ide.vue';
import { createStore } from './stores';
import { createRouter } from './ide_router';
@@ -11,6 +12,10 @@ import { DEFAULT_THEME } from './lib/themes';
Vue.use(Translate);
+Vue.use(PerformancePlugin, {
+ components: ['FileTree'],
+});
+
/**
* Function that receives the default store and returns an extended one.
* @callback extendStoreCallback
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index c5bb00c3dee..2471b3627ce 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -1,7 +1,8 @@
import { editor as monacoEditor, Uri } from 'monaco-editor';
import Disposable from './disposable';
import eventHub from '../../eventhub';
-import { trimTrailingWhitespace, insertFinalNewline } from '../../utils';
+import { trimTrailingWhitespace } from '../../utils';
+import { insertFinalNewline } from '~/lib/utils/text_utility';
import { defaultModelOptions } from '../editor_options';
export default class Model {
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 1496170447d..710256b6377 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -3,6 +3,12 @@ import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import { deprecatedCreateFlash as flash } from '~/flash';
+import { performanceMarkAndMeasure } from '~/performance/utils';
+import {
+ WEBIDE_MARK_FETCH_BRANCH_DATA_START,
+ WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH,
+ WEBIDE_MEASURE_FETCH_BRANCH_DATA,
+} from '~/performance/constants';
import * as types from './mutation_types';
import { decorateFiles } from '../lib/files';
import { stageKeys, commitActionTypes } from '../constants';
@@ -245,13 +251,23 @@ export const renameEntry = ({ dispatch, commit, state, getters }, { path, name,
dispatch('triggerFilesChange', { type: commitActionTypes.move, path, newPath });
};
-export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) =>
- new Promise((resolve, reject) => {
+export const getBranchData = ({ commit, state }, { projectId, branchId, force = false } = {}) => {
+ return new Promise((resolve, reject) => {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_BRANCH_DATA_START });
const currentProject = state.projects[projectId];
if (!currentProject || !currentProject.branches[branchId] || force) {
service
.getBranchData(projectId, branchId)
.then(({ data }) => {
+ performanceMarkAndMeasure({
+ mark: WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_FETCH_BRANCH_DATA,
+ start: WEBIDE_MARK_FETCH_BRANCH_DATA_START,
+ },
+ ],
+ });
const { id } = data.commit;
commit(types.SET_BRANCH, {
projectPath: projectId,
@@ -291,6 +307,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force =
resolve(currentProject.branches[branchId]);
}
});
+};
export * from './actions/tree';
export * from './actions/file';
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 4b9b958ddd6..8b43c7238fd 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -1,5 +1,11 @@
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
+import { performanceMarkAndMeasure } from '~/performance/utils';
+import {
+ WEBIDE_MARK_FETCH_FILE_DATA_START,
+ WEBIDE_MARK_FETCH_FILE_DATA_FINISH,
+ WEBIDE_MEASURE_FETCH_FILE_DATA,
+} from '~/performance/constants';
import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
@@ -61,6 +67,7 @@ export const getFileData = (
{ state, commit, dispatch, getters },
{ path, makeFileActive = true, openFile = makeFileActive, toggleLoading = true },
) => {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_FILE_DATA_START });
const file = state.entries[path];
const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path);
@@ -81,6 +88,15 @@ export const getFileData = (
return service
.getFileData(url)
.then(({ data }) => {
+ performanceMarkAndMeasure({
+ mark: WEBIDE_MARK_FETCH_FILE_DATA_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_FETCH_FILE_DATA,
+ start: WEBIDE_MARK_FETCH_FILE_DATA_START,
+ },
+ ],
+ });
if (data) commit(types.SET_FILE_DATA, { data, file });
if (openFile) commit(types.TOGGLE_FILE_OPEN, path);
@@ -150,6 +166,13 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) =
export const changeFileContent = ({ commit, state, getters }, { path, content }) => {
const file = state.entries[path];
+
+ // It's possible for monaco to hit a race condition where it tries to update renamed files.
+ // See issue https://gitlab.com/gitlab-org/gitlab/-/issues/284930
+ if (!file) {
+ return;
+ }
+
commit(types.UPDATE_FILE_CONTENT, {
path,
content,
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index 3a7daf30cc4..23a5e26bc1c 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -1,4 +1,10 @@
import { defer } from 'lodash';
+import { performanceMarkAndMeasure } from '~/performance/utils';
+import {
+ WEBIDE_MARK_FETCH_FILES_FINISH,
+ WEBIDE_MEASURE_FETCH_FILES,
+ WEBIDE_MARK_FETCH_FILES_START,
+} from '~/performance/constants';
import { __ } from '../../../locale';
import service from '../../services';
import * as types from '../mutation_types';
@@ -46,8 +52,9 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL
});
};
-export const getFiles = ({ state, commit, dispatch }, payload = {}) =>
- new Promise((resolve, reject) => {
+export const getFiles = ({ state, commit, dispatch }, payload = {}) => {
+ performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_FILES_START });
+ return new Promise((resolve, reject) => {
const { projectId, branchId, ref = branchId } = payload;
if (
@@ -61,6 +68,15 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) =>
service
.getFiles(selectedProject.path_with_namespace, ref)
.then(({ data }) => {
+ performanceMarkAndMeasure({
+ mark: WEBIDE_MARK_FETCH_FILES_FINISH,
+ measures: [
+ {
+ name: WEBIDE_MEASURE_FETCH_FILES,
+ start: WEBIDE_MARK_FETCH_FILES_START,
+ },
+ ],
+ });
const { entries, treeList } = decorateFiles({ data });
commit(types.SET_ENTRIES, entries);
@@ -85,6 +101,7 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) =>
resolve();
}
});
+};
export const restoreTree = ({ dispatch, commit, state }, path) => {
const entry = state.entries[path];
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 1ca1b971de1..43276f32322 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -97,10 +97,6 @@ export function trimTrailingWhitespace(content) {
return content.replace(/[^\S\r\n]+$/gm, '');
}
-export function insertFinalNewline(content, eol = '\n') {
- return content.slice(-eol.length) !== eol ? `${content}${eol}` : content;
-}
-
export function getPathParents(path, maxDepth = Infinity) {
const pathComponents = path.split('/');
const paths = [];
diff --git a/app/assets/javascripts/import_projects/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue
index 9e3347a657f..9e3347a657f 100644
--- a/app/assets/javascripts/import_projects/components/import_status.vue
+++ b/app/assets/javascripts/import_entities/components/import_status.vue
diff --git a/app/assets/javascripts/import_projects/constants.js b/app/assets/javascripts/import_entities/constants.js
index ad33ca158d2..ad33ca158d2 100644
--- a/app/assets/javascripts/import_projects/constants.js
+++ b/app/assets/javascripts/import_entities/constants.js
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
new file mode 100644
index 00000000000..153c58b556e
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -0,0 +1,78 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
+import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
+import setTargetNamespaceMutation from '../graphql/mutations/set_target_namespace.mutation.graphql';
+import setNewNameMutation from '../graphql/mutations/set_new_name.mutation.graphql';
+import importGroupMutation from '../graphql/mutations/import_group.mutation.graphql';
+import ImportTableRow from './import_table_row.vue';
+
+const mapApolloMutations = mutations =>
+ Object.fromEntries(
+ Object.entries(mutations).map(([key, mutation]) => [
+ key,
+ function mutate(config) {
+ return this.$apollo.mutate({
+ mutation,
+ ...config,
+ });
+ },
+ ]),
+ );
+
+export default {
+ components: {
+ GlLoadingIcon,
+ ImportTableRow,
+ },
+
+ apollo: {
+ bulkImportSourceGroups: bulkImportSourceGroupsQuery,
+ availableNamespaces: availableNamespacesQuery,
+ },
+
+ methods: {
+ ...mapApolloMutations({
+ setTargetNamespace: setTargetNamespaceMutation,
+ setNewName: setNewNameMutation,
+ importGroup: importGroupMutation,
+ }),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon v-if="$apollo.loading" size="md" class="gl-mt-5" />
+ <div v-else-if="bulkImportSourceGroups.length">
+ <table class="gl-w-full">
+ <thead class="gl-border-solid gl-border-gray-200 gl-border-0 gl-border-b-1">
+ <th class="gl-py-4 import-jobs-from-col">{{ s__('BulkImport|From source group') }}</th>
+ <th class="gl-py-4 import-jobs-to-col">{{ s__('BulkImport|To new group') }}</th>
+ <th class="gl-py-4 import-jobs-status-col">{{ __('Status') }}</th>
+ <th class="gl-py-4 import-jobs-cta-col"></th>
+ </thead>
+ <tbody>
+ <template v-for="group in bulkImportSourceGroups">
+ <import-table-row
+ :key="group.id"
+ :group="group"
+ :available-namespaces="availableNamespaces"
+ @update-target-namespace="
+ setTargetNamespace({
+ variables: { sourceGroupId: group.id, targetNamespace: $event },
+ })
+ "
+ @update-new-name="
+ setNewName({
+ variables: { sourceGroupId: group.id, newName: $event },
+ })
+ "
+ @import-group="importGroup({ variables: { sourceGroupId: group.id } })"
+ />
+ </template>
+ </tbody>
+ </table>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
new file mode 100644
index 00000000000..07603d89f0f
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table_row.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlButton, GlIcon, GlLink, GlFormInput } from '@gitlab/ui';
+import { joinPaths } from '~/lib/utils/url_utility';
+import Select2Select from '~/vue_shared/components/select2_select.vue';
+import ImportStatus from '../../components/import_status.vue';
+import { STATUSES } from '../../constants';
+
+export default {
+ components: {
+ Select2Select,
+ ImportStatus,
+ GlButton,
+ GlLink,
+ GlIcon,
+ GlFormInput,
+ },
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ availableNamespaces: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ isDisabled() {
+ return this.group.status !== STATUSES.NONE;
+ },
+
+ isFinished() {
+ return this.group.status === STATUSES.FINISHED;
+ },
+
+ select2Options() {
+ return {
+ data: this.availableNamespaces.map(namespace => ({
+ id: namespace.full_path,
+ text: namespace.full_path,
+ })),
+ };
+ },
+ },
+ methods: {
+ getPath(group) {
+ return `${group.import_target.target_namespace}/${group.import_target.new_name}`;
+ },
+
+ getFullPath(group) {
+ return joinPaths(gon.relative_url_root || '/', this.getPath(group));
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="gl-border-gray-200 gl-border-0 gl-border-b-1">
+ <td class="gl-p-4">
+ <gl-link :href="group.web_url" target="_blank">
+ {{ group.full_path }} <gl-icon name="external-link" />
+ </gl-link>
+ </td>
+ <td class="gl-p-4">
+ <gl-link v-if="isFinished" :href="getFullPath(group)">{{ getPath(group) }}</gl-link>
+
+ <div
+ v-else
+ class="import-entities-target-select gl-display-flex gl-align-items-stretch"
+ :class="{
+ disabled: isDisabled,
+ }"
+ >
+ <select2-select
+ :disabled="isDisabled"
+ :options="select2Options"
+ :value="group.import_target.target_namespace"
+ @input="$emit('update-target-namespace', $event)"
+ />
+ <div
+ class="import-entities-target-select-separator gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1"
+ >
+ /
+ </div>
+ <gl-form-input
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ :disabled="isDisabled"
+ :value="group.import_target.new_name"
+ @input="$emit('update-new-name', $event)"
+ />
+ </div>
+ </td>
+ <td class="gl-p-4 gl-white-space-nowrap">
+ <import-status :status="group.status" />
+ </td>
+ <td class="gl-p-4">
+ <gl-button
+ v-if="!isDisabled"
+ variant="success"
+ category="secondary"
+ @click="$emit('import-group')"
+ >{{ __('Import') }}</gl-button
+ >
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
new file mode 100644
index 00000000000..4fcaa1b55fc
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -0,0 +1,95 @@
+import axios from '~/lib/utils/axios_utils';
+import createDefaultClient from '~/lib/graphql';
+import { s__ } from '~/locale';
+import createFlash from '~/flash';
+import { STATUSES } from '../../constants';
+import availableNamespacesQuery from './queries/available_namespaces.query.graphql';
+import { SourceGroupsManager } from './services/source_groups_manager';
+import { StatusPoller } from './services/status_poller';
+
+export const clientTypenames = {
+ BulkImportSourceGroup: 'ClientBulkImportSourceGroup',
+ AvailableNamespace: 'ClientAvailableNamespace',
+};
+
+export function createResolvers({ endpoints }) {
+ let statusPoller;
+
+ return {
+ Query: {
+ async bulkImportSourceGroups(_, __, { client }) {
+ const {
+ data: { availableNamespaces },
+ } = await client.query({ query: availableNamespacesQuery });
+
+ return axios.get(endpoints.status).then(({ data }) => {
+ return data.importable_data.map(group => ({
+ __typename: clientTypenames.BulkImportSourceGroup,
+ ...group,
+ status: STATUSES.NONE,
+ import_target: {
+ new_name: group.full_path,
+ target_namespace: availableNamespaces[0].full_path,
+ },
+ }));
+ });
+ },
+
+ availableNamespaces: () =>
+ axios.get(endpoints.availableNamespaces).then(({ data }) =>
+ data.map(namespace => ({
+ __typename: clientTypenames.AvailableNamespace,
+ ...namespace,
+ })),
+ ),
+ },
+ Mutation: {
+ setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) {
+ new SourceGroupsManager({ client }).updateById(sourceGroupId, sourceGroup => {
+ // eslint-disable-next-line no-param-reassign
+ sourceGroup.import_target.target_namespace = targetNamespace;
+ });
+ },
+
+ setNewName(_, { newName, sourceGroupId }, { client }) {
+ new SourceGroupsManager({ client }).updateById(sourceGroupId, sourceGroup => {
+ // eslint-disable-next-line no-param-reassign
+ sourceGroup.import_target.new_name = newName;
+ });
+ },
+
+ async importGroup(_, { sourceGroupId }, { client }) {
+ const groupManager = new SourceGroupsManager({ client });
+ const group = groupManager.findById(sourceGroupId);
+ groupManager.setImportStatus(group, STATUSES.SCHEDULING);
+ try {
+ await axios.post(endpoints.createBulkImport, {
+ bulk_import: [
+ {
+ source_type: 'group_entity',
+ source_full_path: group.full_path,
+ destination_namespace: group.import_target.target_namespace,
+ destination_name: group.import_target.new_name,
+ },
+ ],
+ });
+ groupManager.setImportStatus(group, STATUSES.STARTED);
+ if (!statusPoller) {
+ statusPoller = new StatusPoller({ client, interval: 3000 });
+ statusPoller.startPolling();
+ }
+ } catch (e) {
+ createFlash({
+ message: s__('BulkImport|Importing the group failed'),
+ });
+
+ groupManager.setImportStatus(group, STATUSES.NONE);
+ throw e;
+ }
+ },
+ },
+ };
+}
+
+export const createApolloClient = ({ endpoints }) =>
+ createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true });
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
new file mode 100644
index 00000000000..50774e36599
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql
@@ -0,0 +1,8 @@
+fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
+ id
+ web_url
+ full_path
+ full_name
+ status
+ import_target
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql
new file mode 100644
index 00000000000..412608d3faf
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/import_group.mutation.graphql
@@ -0,0 +1,3 @@
+mutation importGroup($sourceGroupId: String!) {
+ importGroup(sourceGroupId: $sourceGroupId) @client
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql
new file mode 100644
index 00000000000..2bc19891401
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_new_name.mutation.graphql
@@ -0,0 +1,3 @@
+mutation setNewName($newName: String!, $sourceGroupId: String!) {
+ setNewName(newName: $newName, sourceGroupId: $sourceGroupId) @client
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql
new file mode 100644
index 00000000000..fc98a1652c1
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_target_namespace.mutation.graphql
@@ -0,0 +1,3 @@
+mutation setTargetNamespace($targetNamespace: String!, $sourceGroupId: String!) {
+ setTargetNamespace(targetNamespace: $targetNamespace, sourceGroupId: $sourceGroupId) @client
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql
new file mode 100644
index 00000000000..5ab9796b50a
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql
@@ -0,0 +1,6 @@
+query availableNamespaces {
+ availableNamespaces @client {
+ id
+ full_path
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql
new file mode 100644
index 00000000000..8d52d94925c
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql
@@ -0,0 +1,7 @@
+#import "../fragments/bulk_import_source_group_item.fragment.graphql"
+
+query bulkImportSourceGroups {
+ bulkImportSourceGroups @client {
+ ...BulkImportSourceGroupItem
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
new file mode 100644
index 00000000000..f752ecc8cd6
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js
@@ -0,0 +1,45 @@
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import produce from 'immer';
+import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql';
+
+function extractTypeConditionFromFragment(fragment) {
+ return fragment.definitions[0]?.typeCondition.name.value;
+}
+
+function generateGroupId(id) {
+ return defaultDataIdFromObject({
+ __typename: extractTypeConditionFromFragment(ImportSourceGroupFragment),
+ id,
+ });
+}
+
+export class SourceGroupsManager {
+ constructor({ client }) {
+ this.client = client;
+ }
+
+ findById(id) {
+ const cacheId = generateGroupId(id);
+ return this.client.readFragment({ fragment: ImportSourceGroupFragment, id: cacheId });
+ }
+
+ update(group, fn) {
+ this.client.writeFragment({
+ fragment: ImportSourceGroupFragment,
+ id: generateGroupId(group.id),
+ data: produce(group, fn),
+ });
+ }
+
+ updateById(id, fn) {
+ const group = this.findById(id);
+ this.update(group, fn);
+ }
+
+ setImportStatus(group, status) {
+ this.update(group, sourceGroup => {
+ // eslint-disable-next-line no-param-reassign
+ sourceGroup.status = status;
+ });
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
new file mode 100644
index 00000000000..5d2922b0ba8
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/status_poller.js
@@ -0,0 +1,68 @@
+import gql from 'graphql-tag';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import bulkImportSourceGroupsQuery from '../queries/bulk_import_source_groups.query.graphql';
+import { STATUSES } from '../../../constants';
+import { SourceGroupsManager } from './source_groups_manager';
+
+const groupId = i => `group${i}`;
+
+function generateGroupsQuery(groups) {
+ return gql`{
+ ${groups
+ .map(
+ (g, idx) =>
+ `${groupId(idx)}: group(fullPath: "${g.import_target.target_namespace}/${
+ g.import_target.new_name
+ }") { id }`,
+ )
+ .join('\n')}
+ }`;
+}
+
+export class StatusPoller {
+ constructor({ client, interval }) {
+ this.client = client;
+ this.interval = interval;
+ this.timeoutId = null;
+ this.groupManager = new SourceGroupsManager({ client });
+ }
+
+ startPolling() {
+ if (this.timeoutId) {
+ return;
+ }
+
+ this.checkPendingImports();
+ }
+
+ stopPolling() {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ }
+
+ async checkPendingImports() {
+ try {
+ const { bulkImportSourceGroups } = this.client.readQuery({
+ query: bulkImportSourceGroupsQuery,
+ });
+ const groupsInProgress = bulkImportSourceGroups.filter(g => g.status === STATUSES.STARTED);
+ if (groupsInProgress.length) {
+ const { data: results } = await this.client.query({
+ query: generateGroupsQuery(groupsInProgress),
+ fetchPolicy: 'no-cache',
+ });
+ const completedGroups = groupsInProgress.filter((_, idx) => Boolean(results[groupId(idx)]));
+ completedGroups.forEach(group => {
+ this.groupManager.setImportStatus(group, STATUSES.FINISHED);
+ });
+ }
+ } catch (e) {
+ createFlash({
+ message: s__('BulkImport|Update of import statuses with realtime changes failed'),
+ });
+ } finally {
+ this.timeoutId = setTimeout(() => this.checkPendingImports(), this.interval);
+ }
+ }
+}
diff --git a/app/assets/javascripts/import_entities/import_groups/index.js b/app/assets/javascripts/import_entities/import_groups/index.js
new file mode 100644
index 00000000000..bf427075564
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import Translate from '~/vue_shared/translate';
+import { createApolloClient } from './graphql/client_factory';
+import ImportTable from './components/import_table.vue';
+
+Vue.use(Translate);
+Vue.use(VueApollo);
+
+export function mountImportGroupsApp(mountElement) {
+ if (!mountElement) return undefined;
+
+ const { statusPath, availableNamespacesPath, createBulkImportPath } = mountElement.dataset;
+ const apolloProvider = new VueApollo({
+ defaultClient: createApolloClient({
+ endpoints: {
+ status: statusPath,
+ availableNamespaces: availableNamespacesPath,
+ createBulkImport: createBulkImportPath,
+ },
+ }),
+ });
+
+ return new Vue({
+ el: mountElement,
+ apolloProvider,
+ render(createElement) {
+ return createElement(ImportTable);
+ },
+ });
+}
diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue
index bc8aa522596..bc8aa522596 100644
--- a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/bitbucket_status_table.vue
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index 96100e4ac0c..2b6b8b765a2 100644
--- a/app/assets/javascripts/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -178,11 +178,7 @@ export default {
:key="pagePaginationStateKey"
@appear="fetchRepos"
/>
- <gl-loading-icon
- v-if="isLoading"
- class="js-loading-button-icon import-projects-loading-icon"
- size="md"
- />
+ <gl-loading-icon v-if="isLoading" class="import-projects-loading-icon" size="md" />
<div v-if="!isLoading && repositories.length === 0" class="text-center">
<strong>{{ emptyStateText }}</strong>
diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
index 18971313dfe..983abda57f7 100644
--- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue
@@ -3,8 +3,8 @@ import { mapState, mapGetters, mapActions } from 'vuex';
import { GlIcon, GlBadge } from '@gitlab/ui';
import Select2Select from '~/vue_shared/components/select2_select.vue';
import { __ } from '~/locale';
-import ImportStatus from './import_status.vue';
-import { STATUSES } from '../constants';
+import ImportStatus from '../../components/import_status.vue';
+import { STATUSES } from '../../constants';
import { isProjectImportable, isIncompatible, getImportStatus } from '../utils';
export default {
diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_entities/import_projects/index.js
index 79fbd58e355..7373b628f2b 100644
--- a/app/assets/javascripts/import_projects/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/index.js
@@ -1,8 +1,7 @@
import Vue from 'vue';
-import Translate from '../vue_shared/translate';
+import Translate from '~/vue_shared/translate';
+import { parseBoolean } from '~/lib/utils/common_utils';
import ImportProjectsTable from './components/import_projects_table.vue';
-import { parseBoolean } from '../lib/utils/common_utils';
-import { queryToObject } from '../lib/utils/url_utility';
import createStore from './store';
Vue.use(Translate);
@@ -20,18 +19,12 @@ export function initStoreFromElement(element) {
paginatable,
} = element.dataset;
- const params = queryToObject(document.location.search);
- const page = parseInt(params.page ?? 1, 10);
-
return createStore({
initialState: {
defaultTargetNamespace: gon.current_username,
ciCdOnly: parseBoolean(ciCdOnly),
canSelectNamespace: parseBoolean(canSelectNamespace),
provider,
- pageInfo: {
- page,
- },
},
endpoints: {
reposPath,
diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js
index 7b7afd13c55..7b7afd13c55 100644
--- a/app/assets/javascripts/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js
diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_entities/import_projects/store/getters.js
index b76c52beea2..31e22b50554 100644
--- a/app/assets/javascripts/import_projects/store/getters.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/getters.js
@@ -1,4 +1,4 @@
-import { STATUSES } from '../constants';
+import { STATUSES } from '../../constants';
import { isProjectImportable, isIncompatible } from '../utils';
export const isLoading = state => state.isLoadingRepos || state.isLoadingNamespaces;
diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_entities/import_projects/store/index.js
index 7ba12f81eb9..7ba12f81eb9 100644
--- a/app/assets/javascripts/import_projects/store/index.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/index.js
diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js
index 6adf5e59cff..6adf5e59cff 100644
--- a/app/assets/javascripts/import_projects/store/mutation_types.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutation_types.js
diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
index 6999253d4b2..3d718a6a386 100644
--- a/app/assets/javascripts/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import * as types from './mutation_types';
-import { STATUSES } from '../constants';
+import { STATUSES } from '../../constants';
const makeNewImportedProject = importedProject => ({
importSource: {
diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_entities/import_projects/store/state.js
index ecd93561d52..ecd93561d52 100644
--- a/app/assets/javascripts/import_projects/store/state.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/state.js
diff --git a/app/assets/javascripts/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js
index 695b12cbcba..0610117e09b 100644
--- a/app/assets/javascripts/import_projects/utils.js
+++ b/app/assets/javascripts/import_entities/import_projects/utils.js
@@ -1,4 +1,4 @@
-import { STATUSES } from './constants';
+import { STATUSES } from '../constants';
export function isIncompatible(project) {
return project.importSource.incompatible;
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
deleted file mode 100644
index 078c50ee9c6..00000000000
--- a/app/assets/javascripts/importer_status.js
+++ /dev/null
@@ -1,149 +0,0 @@
-import $ from 'jquery';
-import { escape } from 'lodash';
-import { __, sprintf } from './locale';
-import axios from './lib/utils/axios_utils';
-import { deprecatedCreateFlash as flash } from './flash';
-import { parseBoolean, spriteIcon } from './lib/utils/common_utils';
-
-class ImporterStatus {
- constructor({ jobsUrl, importUrl, ciCdOnly }) {
- this.jobsUrl = jobsUrl;
- this.importUrl = importUrl;
- this.ciCdOnly = ciCdOnly;
- this.initStatusPage();
- this.setAutoUpdate();
- }
-
- initStatusPage() {
- $('.js-add-to-import')
- .off('click')
- .on('click', this.addToImport.bind(this));
-
- $('.js-import-all')
- .off('click')
- .on('click', function onClickImportAll() {
- const $btn = $(this);
- $btn.disable().addClass('is-loading');
- return $('.js-add-to-import').each(function triggerAddImport() {
- return $(this).trigger('click');
- });
- });
- }
-
- addToImport(event) {
- const $btn = $(event.currentTarget);
- const $tr = $btn.closest('tr');
- const $targetField = $tr.find('.import-target');
- const $namespaceInput = $targetField.find('.js-select-namespace option:selected');
- const repoData = $tr.data();
- const id = repoData.id || $tr.attr('id').replace('repo_', '');
-
- let targetNamespace;
- let newName;
- if ($namespaceInput.length > 0) {
- targetNamespace = $namespaceInput[0].innerHTML;
- newName = $targetField.find('#path').prop('value');
- $targetField.empty().append(`${targetNamespace}/${newName}`);
- }
- $btn.disable().addClass('is-loading');
-
- this.id = id;
-
- let attributes = {
- repo_id: id,
- target_namespace: targetNamespace,
- new_name: newName,
- ci_cd_only: this.ciCdOnly,
- };
-
- if (repoData) {
- attributes = Object.assign(repoData, attributes);
- }
-
- return axios
- .post(this.importUrl, attributes)
- .then(({ data }) => {
- const job = $tr;
- job.attr('id', `project_${data.id}`);
-
- job.find('.import-target').html(`<a href="${data.full_path}">${data.full_path}</a>`);
- $('table.import-jobs tbody').prepend(job);
-
- job.addClass('table-active');
- const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing');
- job.find('.import-actions').html(
- sprintf(
- escape(__('%{loadingIcon} Started')),
- {
- loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${escape(
- connectingVerb,
- )}"></i>`,
- },
- false,
- ),
- );
- })
- .catch(error => {
- let details = error;
-
- const $statusField = $tr.find('.job-status');
- $statusField.text(__('Failed'));
-
- if (error.response && error.response.data && error.response.data.errors) {
- details = error.response.data.errors;
- }
-
- flash(sprintf(__('An error occurred while importing project: %{details}'), { details }));
- });
- }
-
- autoUpdate() {
- return axios.get(this.jobsUrl).then(({ data = [] }) => {
- data.forEach(job => {
- const jobItem = $(`#project_${job.id}`);
- const statusField = jobItem.find('.job-status');
-
- const spinner = '<i class="fa fa-spinner fa-spin"></i>';
-
- switch (job.import_status) {
- case 'finished':
- jobItem.removeClass('table-active').addClass('table-success');
- statusField.html(`<span>${spriteIcon('check', 's16')} ${__('Done')}</span>`);
- break;
- case 'scheduled':
- statusField.html(`${spinner} ${__('Scheduled')}`);
- break;
- case 'started':
- statusField.html(`${spinner} ${__('Started')}`);
- break;
- case 'failed':
- statusField.html(__('Failed'));
- break;
- default:
- statusField.html(job.import_status);
- break;
- }
- });
- });
- }
-
- setAutoUpdate() {
- setInterval(this.autoUpdate.bind(this), 4000);
- }
-}
-
-// eslint-disable-next-line consistent-return
-function initImporterStatus() {
- const importerStatus = document.querySelector('.js-importer-status');
-
- if (importerStatus) {
- const data = importerStatus.dataset;
- return new ImporterStatus({
- jobsUrl: data.jobsImportPath,
- importUrl: data.importPath,
- ciCdOnly: parseBoolean(data.ciCdOnly),
- });
- }
-}
-
-export { initImporterStatus as default, ImporterStatus };
diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue
index 5fe0badc56e..e8daad8811e 100644
--- a/app/assets/javascripts/incidents_settings/components/alerts_form.vue
+++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue
@@ -86,7 +86,7 @@ export default {
<form ref="settingsForm" @submit.prevent="updateAlertsIntegrationSettings">
<gl-form-group class="gl-pl-0">
<gl-form-checkbox v-model="createIssueEnabled" data-qa-selector="create_issue_checkbox">
- <span>{{ $options.i18n.createIssue.label }}</span>
+ <span>{{ $options.i18n.createIncident.label }}</span>
</gl-form-checkbox>
</gl-form-group>
@@ -96,7 +96,7 @@ export default {
class="col-8 col-md-9 gl-px-6"
>
<label class="gl-display-inline-flex" for="alert-integration-settings-issue-template">
- {{ $options.i18n.issueTemplate.label }}
+ {{ $options.i18n.incidentTemplate.label }}
<gl-link :href="$options.ISSUE_TEMPLATES_DOCS_LINK" target="_blank">
<gl-icon name="question" :size="12" />
</gl-link>
diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
index 9a8c4bc5af9..b56dd66342a 100644
--- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
+++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue
@@ -109,7 +109,20 @@ export default {
{{ webhookUpdateAlertMsg }}
</gl-alert>
- <p>{{ $options.i18n.introText }}</p>
+ <p>
+ <gl-sprintf :message="$options.i18n.introText">
+ <template #link="{ content }">
+ <gl-link
+ :href="$options.CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK"
+ target="_blank"
+ class="gl-display-inline-flex"
+ >
+ <span>{{ content }}</span>
+ <gl-icon name="external-link" />
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
<form ref="settingsForm" @submit.prevent="updatePagerDutyIntegrationSettings">
<gl-form-group class="col-8 col-md-9 gl-p-0">
<gl-toggle
@@ -134,23 +147,9 @@ export default {
</template>
</gl-form-input-group>
- <div class="gl-text-gray-200 gl-pt-2">
- <gl-sprintf :message="$options.i18n.webhookUrl.helpText">
- <template #docsLink>
- <gl-link
- :href="$options.CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK"
- target="_blank"
- class="gl-display-inline-flex"
- >
- <span>{{ $options.i18n.webhookUrl.helpDocsLink }}</span>
- <gl-icon name="external-link" />
- </gl-link>
- </template>
- </gl-sprintf>
- </div>
<gl-button
v-gl-modal.resetWebhookModal
- class="gl-mt-3"
+ class="gl-mt-5"
:disabled="loading"
:loading="resettingWebhook"
data-testid="webhook-reset-btn"
diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js
index 42f1f645d16..fcac9c519c2 100644
--- a/app/assets/javascripts/incidents_settings/constants.js
+++ b/app/assets/javascripts/incidents_settings/constants.js
@@ -33,17 +33,17 @@ export const I18N_ALERT_SETTINGS_FORM = {
saveBtnLabel: __('Save changes'),
introText: __('Action to take when receiving an alert. %{docsLink}'),
introLinkText: __('More information.'),
- createIssue: {
- label: __('Create an issue. Issues are created for each alert triggered.'),
+ createIncident: {
+ label: __('Create an incident. Incidents are created for each alert triggered.'),
},
- issueTemplate: {
- label: __('Issue template (optional)'),
+ incidentTemplate: {
+ label: __('Incident template (optional)'),
},
sendEmail: {
label: __('Send a separate email notification to Developers.'),
},
autoCloseIncidents: {
- label: __('Automatically close incident issues when the associated Prometheus alert resolves.'),
+ label: __('Automatically close incidents when the associated Prometheus alert resolves.'),
},
};
@@ -57,17 +57,13 @@ export const ISSUE_TEMPLATES_DOCS_LINK =
export const I18N_PAGERDUTY_SETTINGS_FORM = {
introText: s__(
- 'PagerDutySettings|Setting up a webhook with PagerDuty will automatically create a GitLab issue for each PagerDuty incident.',
+ 'PagerDutySettings|Create a GitLab incident for each PagerDuty incident by %{linkStart}configuring a webhook in PagerDuty%{linkEnd}',
),
activeToggle: {
label: s__('PagerDutySettings|Active'),
},
webhookUrl: {
label: s__('PagerDutySettings|Webhook URL'),
- helpText: s__(
- 'PagerDutySettings|Create a GitLab issue for each PagerDuty incident by %{docsLink}',
- ),
- helpDocsLink: s__('PagerDutySettings|configuring a webhook in PagerDuty'),
resetWebhookUrl: s__('PagerDutySettings|Reset webhook URL'),
copyToClipboard: __('Copy'),
updateErrMsg: s__('PagerDutySettings|Failed to update Webhook URL'),
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
index bbfa865905a..c6f8ba8dcb2 100644
--- a/app/assets/javascripts/integrations/edit/components/integration_form.vue
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -33,7 +33,14 @@ export default {
mixins: [glFeatureFlagsMixin()],
computed: {
...mapGetters(['currentKey', 'propsSource', 'isDisabled']),
- ...mapState(['defaultState', 'override', 'isSaving', 'isTesting', 'isResetting']),
+ ...mapState([
+ 'defaultState',
+ 'customState',
+ 'override',
+ 'isSaving',
+ 'isTesting',
+ 'isResetting',
+ ]),
isEditable() {
return this.propsSource.editable;
},
@@ -42,8 +49,8 @@ export default {
},
isInstanceOrGroupLevel() {
return (
- this.propsSource.integrationLevel === integrationLevels.INSTANCE ||
- this.propsSource.integrationLevel === integrationLevels.GROUP
+ this.customState.integrationLevel === integrationLevels.INSTANCE ||
+ this.customState.integrationLevel === integrationLevels.GROUP
);
},
showJiraIssuesFields() {
@@ -52,9 +59,18 @@ export default {
showReset() {
return this.isInstanceOrGroupLevel && this.propsSource.resetPath;
},
+ saveButtonKey() {
+ return `save-button-${this.isDisabled}`;
+ },
},
methods: {
- ...mapActions(['setOverride', 'setIsSaving', 'setIsTesting', 'setIsResetting']),
+ ...mapActions([
+ 'setOverride',
+ 'setIsSaving',
+ 'setIsTesting',
+ 'setIsResetting',
+ 'fetchResetIntegration',
+ ]),
onSaveClick() {
this.setIsSaving(true);
eventHub.$emit('saveIntegration');
@@ -63,7 +79,9 @@ export default {
this.setIsTesting(true);
eventHub.$emit('testIntegration');
},
- onResetClick() {},
+ onResetClick() {
+ this.fetchResetIntegration();
+ },
},
};
</script>
@@ -102,6 +120,7 @@ export default {
<div v-if="isEditable" class="footer-block row-content-block">
<template v-if="isInstanceOrGroupLevel">
<gl-button
+ :key="saveButtonKey"
v-gl-modal.confirmSaveIntegration
category="primary"
variant="success"
@@ -115,6 +134,7 @@ export default {
</template>
<gl-button
v-else
+ :key="saveButtonKey"
category="primary"
variant="success"
type="submit"
diff --git a/app/assets/javascripts/integrations/edit/store/actions.js b/app/assets/javascripts/integrations/edit/store/actions.js
index 097304be242..421917b720a 100644
--- a/app/assets/javascripts/integrations/edit/store/actions.js
+++ b/app/assets/javascripts/integrations/edit/store/actions.js
@@ -1,3 +1,5 @@
+import axios from 'axios';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
import * as types from './mutation_types';
export const setOverride = ({ commit }, override) => commit(types.SET_OVERRIDE, override);
@@ -5,3 +7,22 @@ export const setIsSaving = ({ commit }, isSaving) => commit(types.SET_IS_SAVING,
export const setIsTesting = ({ commit }, isTesting) => commit(types.SET_IS_TESTING, isTesting);
export const setIsResetting = ({ commit }, isResetting) =>
commit(types.SET_IS_RESETTING, isResetting);
+
+export const requestResetIntegration = ({ commit }) => {
+ commit(types.REQUEST_RESET_INTEGRATION);
+};
+export const receiveResetIntegrationSuccess = () => {
+ refreshCurrentPage();
+};
+export const receiveResetIntegrationError = ({ commit }) => {
+ commit(types.RECEIVE_RESET_INTEGRATION_ERROR);
+};
+
+export const fetchResetIntegration = ({ dispatch, getters }) => {
+ dispatch('requestResetIntegration');
+
+ return axios
+ .post(getters.propsSource.resetPath, { params: { format: 'json' } })
+ .then(() => dispatch('receiveResetIntegrationSuccess'))
+ .catch(() => dispatch('receiveResetIntegrationError'));
+};
diff --git a/app/assets/javascripts/integrations/edit/store/mutation_types.js b/app/assets/javascripts/integrations/edit/store/mutation_types.js
index 2a84408f658..54928148b22 100644
--- a/app/assets/javascripts/integrations/edit/store/mutation_types.js
+++ b/app/assets/javascripts/integrations/edit/store/mutation_types.js
@@ -2,3 +2,6 @@ export const SET_OVERRIDE = 'SET_OVERRIDE';
export const SET_IS_SAVING = 'SET_IS_SAVING';
export const SET_IS_TESTING = 'SET_IS_TESTING';
export const SET_IS_RESETTING = 'SET_IS_RESETTING';
+
+export const REQUEST_RESET_INTEGRATION = 'REQUEST_RESET_INTEGRATION';
+export const RECEIVE_RESET_INTEGRATION_ERROR = 'RECEIVE_RESET_INTEGRATION_ERROR';
diff --git a/app/assets/javascripts/integrations/edit/store/mutations.js b/app/assets/javascripts/integrations/edit/store/mutations.js
index 07e3e25ccf0..826757e665b 100644
--- a/app/assets/javascripts/integrations/edit/store/mutations.js
+++ b/app/assets/javascripts/integrations/edit/store/mutations.js
@@ -13,4 +13,10 @@ export default {
[types.SET_IS_RESETTING](state, isResetting) {
state.isResetting = isResetting;
},
+ [types.REQUEST_RESET_INTEGRATION](state) {
+ state.isResetting = true;
+ },
+ [types.RECEIVE_RESET_INTEGRATION_ERROR](state) {
+ state.isResetting = false;
+ },
};
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index 1d0814125e6..14d6f133d27 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -35,12 +35,14 @@ export default class IntegrationSettingsForm {
}
saveIntegration() {
- // Service was marked active so now we check;
+ // Save Service if not active and check the following if active;
// 1) If form contents are valid
// 2) If this service can be saved
// If both conditions are true, we override form submission
// and save the service using provided configuration.
- if (this.$form.get(0).checkValidity()) {
+ const formValid = this.$form.get(0).checkValidity() || this.formActive === false;
+
+ if (formValid) {
this.$form.submit();
} else {
eventHub.$emit('validateForm');
diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
index e0fb58ef195..12f03873958 100644
--- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { loadCSSFile } from '../lib/utils/css_utils';
let instanceCount = 0;
@@ -13,10 +14,15 @@ class AutoWidthDropdownSelect {
const { dropdownClass } = this;
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
- this.$selectElement.select2({
- dropdownCssClass: dropdownClass,
- ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass),
- });
+ // eslint-disable-next-line promise/no-nesting
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ this.$selectElement.select2({
+ dropdownCssClass: dropdownClass,
+ ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass),
+ });
+ })
+ .catch(() => {});
})
.catch(() => {});
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index c7806fc17fc..6ba21cd7869 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -15,6 +15,7 @@ export default {
},
bindEvents() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
},
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 6f2bd2da078..2072e41514d 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import Cookies from 'js-cookie';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import UsersSelect from './users_select';
+import { loadCSSFile } from './lib/utils/css_utils';
export default class IssuableContext {
constructor(currentUser) {
@@ -10,10 +11,15 @@ export default class IssuableContext {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
- $('select.select2').select2({
- width: 'resolve',
- dropdownAutoWidth: true,
- });
+ // eslint-disable-next-line promise/no-nesting
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ $('select.select2').select2({
+ width: 'resolve',
+ dropdownAutoWidth: true,
+ });
+ })
+ .catch(() => {});
})
.catch(() => {});
diff --git a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue b/app/assets/javascripts/issuable_create/components/issuable_create_root.vue
index 1ef42976032..f4cbaba9313 100644
--- a/app/assets/javascripts/issuable_create/components/issuable_create_root.vue
+++ b/app/assets/javascripts/issuable_create/components/issuable_create_root.vue
@@ -29,7 +29,7 @@ export default {
<template>
<div class="issuable-create-container">
<slot name="title"></slot>
- <hr />
+ <hr class="gl-mt-0" />
<issuable-form
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index ed34e2f5623..791b5fef699 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -7,6 +7,7 @@ import ZenMode from './zen_mode';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
import { queryToObject, objectToQuery } from './lib/utils/url_utility';
+import { loadCSSFile } from './lib/utils/css_utils';
const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
const MR_TARGET_BRANCH = 'merge_request[target_branch]';
@@ -184,36 +185,41 @@ export default class IssuableForm {
initTargetBranchDropdown() {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
- this.$targetBranchSelect.select2({
- ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'),
- ajax: {
- url: this.$targetBranchSelect.data('endpoint'),
- dataType: 'JSON',
- quietMillis: 250,
- data(search) {
- return {
- search,
- };
- },
- results(data) {
- return {
- // `data` keys are translated so we can't just access them with a string based key
- results: data[Object.keys(data)[0]].map(name => ({
- id: name,
- text: name,
- })),
- };
- },
- },
- initSelection(el, callback) {
- const val = el.val();
-
- callback({
- id: val,
- text: val,
+ // eslint-disable-next-line promise/no-nesting
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ this.$targetBranchSelect.select2({
+ ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'),
+ ajax: {
+ url: this.$targetBranchSelect.data('endpoint'),
+ dataType: 'JSON',
+ quietMillis: 250,
+ data(search) {
+ return {
+ search,
+ };
+ },
+ results(data) {
+ return {
+ // `data` keys are translated so we can't just access them with a string based key
+ results: data[Object.keys(data)[0]].map(name => ({
+ id: name,
+ text: name,
+ })),
+ };
+ },
+ },
+ initSelection(el, callback) {
+ const val = el.val();
+
+ callback({
+ id: val,
+ text: val,
+ });
+ },
});
- },
- });
+ })
+ .catch(() => {});
})
.catch(() => {});
}
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index 1ee794ab208..583e5cb703d 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -128,7 +128,7 @@ export default {
<template>
<li class="issue gl-px-5!">
- <div class="issue-box">
+ <div class="issuable-info-container">
<div v-if="showCheckbox" class="issue-check">
<gl-form-checkbox
class="gl-mr-0"
@@ -136,101 +136,99 @@ export default {
@input="$emit('checked-input', $event)"
/>
</div>
- <div class="issuable-info-container">
- <div class="issuable-main-info">
- <div data-testid="issuable-title" class="issue-title title">
- <span class="issue-title-text" dir="auto">
- <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps"
- >{{ issuable.title
- }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
- /></gl-link>
- </span>
- </div>
- <div class="issuable-info">
- <slot v-if="hasSlotContents('reference')" name="reference"></slot>
- <span v-else data-testid="issuable-reference" class="issuable-reference"
- >{{ issuableSymbol }}{{ issuable.iid }}</span
- >
- <span class="issuable-authored d-none d-sm-inline-block">
- &middot;
- <span
- v-gl-tooltip:tooltipcontainer.bottom
- data-testid="issuable-created-at"
- :title="tooltipTitle(issuable.createdAt)"
- >{{ createdAt }}</span
- >
- {{ __('by') }}
- <slot v-if="hasSlotContents('author')" name="author"></slot>
- <gl-link
- v-else
- :data-user-id="authorId"
- :data-username="author.username"
- :data-name="author.name"
- :data-avatar-url="author.avatarUrl"
- :href="author.webUrl"
- data-testid="issuable-author"
- class="author-link js-user-link"
- >
- <span class="author">{{ author.name }}</span>
- </gl-link>
- </span>
- <slot name="timeframe"></slot>
- &nbsp;
- <gl-label
- v-for="(label, index) in labels"
- :key="index"
- :background-color="label.color"
- :title="labelTitle(label)"
- :description="label.description"
- :scoped="scopedLabel(label)"
- :target="labelTarget(label)"
- :class="{ 'gl-ml-2': index }"
- size="sm"
- />
- </div>
+ <div class="issuable-main-info">
+ <div data-testid="issuable-title" class="issue-title title">
+ <span class="issue-title-text" dir="auto">
+ <gl-link :href="issuable.webUrl" v-bind="issuableTitleProps"
+ >{{ issuable.title
+ }}<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2"
+ /></gl-link>
+ </span>
</div>
- <div class="issuable-meta">
- <ul v-if="showIssuableMeta" class="controls">
- <li v-if="hasSlotContents('status')" class="issuable-status">
- <slot name="status"></slot>
- </li>
- <li
- v-if="showDiscussions"
- data-testid="issuable-discussions"
- class="issuable-comments gl-display-none gl-display-sm-block"
- >
- <gl-link
- v-gl-tooltip:tooltipcontainer.top
- :title="__('Comments')"
- :href="`${issuable.webUrl}#notes`"
- :class="{ 'no-comments': !issuable.userDiscussionsCount }"
- class="gl-reset-color!"
- >
- <gl-icon name="comments" />
- {{ issuable.userDiscussionsCount }}
- </gl-link>
- </li>
- <li v-if="assignees.length" class="gl-display-flex">
- <issuable-assignees
- :assignees="issuable.assignees"
- :icon-size="16"
- :max-visible="4"
- img-css-classes="gl-mr-2!"
- class="gl-align-items-center gl-display-flex gl-ml-3"
- />
- </li>
- </ul>
- <div
- data-testid="issuable-updated-at"
- class="float-right issuable-updated-at d-none d-sm-inline-block"
+ <div class="issuable-info">
+ <slot v-if="hasSlotContents('reference')" name="reference"></slot>
+ <span v-else data-testid="issuable-reference" class="issuable-reference"
+ >{{ issuableSymbol }}{{ issuable.iid }}</span
>
+ <span class="issuable-authored d-none d-sm-inline-block">
+ &middot;
<span
v-gl-tooltip:tooltipcontainer.bottom
- :title="tooltipTitle(issuable.updatedAt)"
- class="issuable-updated-at"
- >{{ updatedAt }}</span
+ data-testid="issuable-created-at"
+ :title="tooltipTitle(issuable.createdAt)"
+ >{{ createdAt }}</span
+ >
+ {{ __('by') }}
+ <slot v-if="hasSlotContents('author')" name="author"></slot>
+ <gl-link
+ v-else
+ :data-user-id="authorId"
+ :data-username="author.username"
+ :data-name="author.name"
+ :data-avatar-url="author.avatarUrl"
+ :href="author.webUrl"
+ data-testid="issuable-author"
+ class="author-link js-user-link"
+ >
+ <span class="author">{{ author.name }}</span>
+ </gl-link>
+ </span>
+ <slot name="timeframe"></slot>
+ &nbsp;
+ <gl-label
+ v-for="(label, index) in labels"
+ :key="index"
+ :background-color="label.color"
+ :title="labelTitle(label)"
+ :description="label.description"
+ :scoped="scopedLabel(label)"
+ :target="labelTarget(label)"
+ :class="{ 'gl-ml-2': index }"
+ size="sm"
+ />
+ </div>
+ </div>
+ <div class="issuable-meta">
+ <ul v-if="showIssuableMeta" class="controls">
+ <li v-if="hasSlotContents('status')" class="issuable-status">
+ <slot name="status"></slot>
+ </li>
+ <li
+ v-if="showDiscussions"
+ data-testid="issuable-discussions"
+ class="issuable-comments gl-display-none gl-display-sm-block"
+ >
+ <gl-link
+ v-gl-tooltip:tooltipcontainer.top
+ :title="__('Comments')"
+ :href="`${issuable.webUrl}#notes`"
+ :class="{ 'no-comments': !issuable.userDiscussionsCount }"
+ class="gl-reset-color!"
>
- </div>
+ <gl-icon name="comments" />
+ {{ issuable.userDiscussionsCount }}
+ </gl-link>
+ </li>
+ <li v-if="assignees.length" class="gl-display-flex">
+ <issuable-assignees
+ :assignees="issuable.assignees"
+ :icon-size="16"
+ :max-visible="4"
+ img-css-classes="gl-mr-2!"
+ class="gl-align-items-center gl-display-flex gl-ml-3"
+ />
+ </li>
+ </ul>
+ <div
+ data-testid="issuable-updated-at"
+ class="float-right issuable-updated-at d-none d-sm-inline-block"
+ >
+ <span
+ v-gl-tooltip:tooltipcontainer.bottom
+ :title="tooltipTitle(issuable.updatedAt)"
+ class="issuable-updated-at"
+ >{{ updatedAt }}</span
+ >
</div>
</div>
</div>
diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue
index e6a05c1ab8b..c084f328f42 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_body.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue
@@ -36,10 +36,18 @@ export default {
type: Boolean,
required: true,
},
+ enableAutosave: {
+ type: Boolean,
+ required: true,
+ },
editFormVisible: {
type: Boolean,
required: true,
},
+ showFieldTitle: {
+ type: Boolean,
+ required: true,
+ },
descriptionPreviewPath: {
type: String,
required: true,
@@ -57,6 +65,14 @@ export default {
return this.issuable.updatedBy;
},
},
+ methods: {
+ handleKeydownTitle(e, issuableMeta) {
+ this.$emit('keydown-title', e, issuableMeta);
+ },
+ handleKeydownDescription(e, issuableMeta) {
+ this.$emit('keydown-description', e, issuableMeta);
+ },
+ },
};
</script>
@@ -67,8 +83,12 @@ export default {
v-if="editFormVisible"
:issuable="issuable"
:enable-autocomplete="enableAutocomplete"
+ :enable-autosave="enableAutosave"
+ :show-field-title="showFieldTitle"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
+ @keydown-title="handleKeydownTitle"
+ @keydown-description="handleKeydownDescription"
>
<template #edit-form-actions="issuableMeta">
<slot name="edit-form-actions" v-bind="issuableMeta"></slot>
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 7b9a83a740f..93e4db8b99c 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue
@@ -23,6 +23,14 @@ export default {
type: Boolean,
required: true,
},
+ enableAutosave: {
+ type: Boolean,
+ required: true,
+ },
+ showFieldTitle: {
+ type: Boolean,
+ required: true,
+ },
descriptionPreviewPath: {
type: String,
required: true,
@@ -33,19 +41,27 @@ export default {
},
},
data() {
- const { title, description } = this.issuable;
-
return {
- title,
- description,
+ title: '',
+ description: '',
};
},
+ watch: {
+ issuable: {
+ handler(value) {
+ this.title = value?.title || '';
+ this.description = value?.description || '';
+ },
+ deep: true,
+ immediate: true,
+ },
+ },
created() {
eventHub.$on('update.issuable', this.resetAutosave);
eventHub.$on('close.form', this.resetAutosave);
},
mounted() {
- this.initAutosave();
+ if (this.enableAutosave) this.initAutosave();
},
beforeDestroy() {
eventHub.$off('update.issuable', this.resetAutosave);
@@ -73,6 +89,12 @@ export default {
this.autosaveTitle.reset();
this.autosaveDescription.reset();
},
+ handleKeydown(e, inputType) {
+ this.$emit(`keydown-${inputType}`, e, {
+ issuableTitle: this.title,
+ issuableDescription: this.description,
+ });
+ },
},
};
</script>
@@ -82,9 +104,9 @@ export default {
<gl-form-group
data-testid="title"
:label="__('Title')"
- :label-sr-only="true"
+ :label-sr-only="!showFieldTitle"
label-for="issuable-title"
- class="col-12"
+ class="col-12 gl-px-0"
>
<gl-form-input
id="issuable-title"
@@ -94,14 +116,16 @@ export default {
:aria-label="__('Title')"
:autofocus="true"
class="qa-title-input"
+ @keydown="handleKeydown($event, 'title')"
/>
</gl-form-group>
<gl-form-group
data-testid="description"
:label="__('Description')"
- :label-sr-only="true"
+ :label-sr-only="!showFieldTitle"
label-for="issuable-description"
- class="col-12 common-note-form"
+ label-class="gl-pb-0!"
+ class="col-12 gl-px-0 common-note-form"
>
<markdown-field
:markdown-preview-path="descriptionPreviewPath"
@@ -120,11 +144,12 @@ export default {
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
dir="auto"
+ @keydown="handleKeydown($event, 'description')"
></textarea>
</template>
</markdown-field>
</gl-form-group>
- <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 clearfix">
+ <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 gl-px-0 clearfix">
<slot
name="edit-form-actions"
:issuable-title="title"
diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/issuable_show/components/issuable_header.vue
index 3815c50cac6..5404753631d 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_header.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_header.vue
@@ -112,7 +112,7 @@ export default {
</div>
<div
data-testid="header-actions"
- class="detail-page-header-actions js-issuable-actions js-issuable-buttons gl-display-flex gl-display-md-block"
+ class="detail-page-header-actions gl-display-flex gl-display-md-block"
>
<slot name="header-actions"></slot>
</div>
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 b41f5e270a8..2443338e8c4 100644
--- a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
+++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue
@@ -35,11 +35,21 @@ export default {
required: false,
default: false,
},
+ enableAutosave: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
editFormVisible: {
type: Boolean,
required: false,
default: false,
},
+ showFieldTitle: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
descriptionPreviewPath: {
type: String,
required: false,
@@ -51,6 +61,14 @@ export default {
default: '',
},
},
+ methods: {
+ handleKeydownTitle(e, issuableMeta) {
+ this.$emit('keydown-title', e, issuableMeta);
+ },
+ handleKeydownDescription(e, issuableMeta) {
+ this.$emit('keydown-description', e, issuableMeta);
+ },
+ },
};
</script>
@@ -77,10 +95,14 @@ export default {
:status-icon="statusIcon"
:enable-edit="enableEdit"
:enable-autocomplete="enableAutocomplete"
+ :enable-autosave="enableAutosave"
:edit-form-visible="editFormVisible"
+ :show-field-title="showFieldTitle"
:description-preview-path="descriptionPreviewPath"
:description-help-path="descriptionHelpPath"
@edit-issuable="$emit('edit-issuable', $event)"
+ @keydown-title="handleKeydownTitle"
+ @keydown-description="handleKeydownDescription"
>
<template #status-badge>
<slot name="status-badge"></slot>
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index f65d9259e7b..5d2880c3c10 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,41 +1,22 @@
-/* eslint-disable consistent-return */
-
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import { addDelimiter } from './lib/utils/text_utility';
import { deprecatedCreateFlash as flash } from './flash';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
-import IssuablesHelper from './helpers/issuables_helper';
import { joinPaths } from '~/lib/utils/url_utility';
import { __ } from './locale';
export default class Issue {
constructor() {
- if ($('.btn-close, .btn-reopen').length) this.initIssueBtnEventListeners();
-
- if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener();
-
if ($('.js-alert-moved-from-service-desk-warning').length) {
- const trimmedPathname = window.location.pathname.slice(1);
- this.alertMovedFromServiceDeskDismissedKey = joinPaths(
- trimmedPathname,
- 'alert-issue-moved-from-service-desk-dismissed',
- );
-
- this.initIssueMovedFromServiceDeskDismissHandler();
+ Issue.initIssueMovedFromServiceDeskDismissHandler();
}
- Issue.$btnNewBranch = $('#new-branch');
- Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
-
if (document.querySelector('#related-branches')) {
Issue.initRelatedBranches();
}
- this.closeButtons = $('.btn-close');
- this.reopenButtons = $('.btn-reopen');
-
- this.initCloseReopenReport();
+ Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
if (Issue.createMrDropdownWrap) {
this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap);
@@ -71,7 +52,6 @@ export default class Issue {
isOpenBadge.toggleClass('hidden', isClosed);
$(document).trigger('issuable:change', isClosed);
- this.toggleCloseReopenButton(isClosed);
let numProjectIssues = Number(
projectIssuesCounter
@@ -91,104 +71,16 @@ export default class Issue {
}
}
- initIssueBtnEventListeners() {
- const issueFailMessage = __('Unable to update this issue at this time.');
-
- $('.report-abuse-link').on('click', e => {
- // this is needed because of the implementation of
- // the dropdown toggle and Report Abuse needing to be
- // linked to another page.
- e.stopPropagation();
- });
-
- // NOTE: data attribute seems unnecessary but is actually necessary
- return $('.js-issuable-buttons[data-action="close-reopen"]').on(
- 'click',
- '.btn-close, .btn-reopen, .btn-close-anyway',
- e => {
- e.preventDefault();
- e.stopImmediatePropagation();
- const $button = $(e.currentTarget);
- const shouldSubmit = $button.hasClass('btn-comment');
- if (shouldSubmit) {
- Issue.submitNoteForm($button.closest('form'));
- }
-
- const shouldDisplayBlockedWarning = $button.hasClass('btn-issue-blocked');
- const warningBanner = $('.js-close-blocked-issue-warning');
- if (shouldDisplayBlockedWarning) {
- this.toggleWarningAndCloseButton();
- } else {
- this.disableCloseReopenButton($button);
-
- const url = $button.data('endpoint');
-
- return axios
- .put(url)
- .then(({ data }) => {
- const isClosed = $button.is('.btn-close, .btn-close-anyway');
- this.updateTopState(isClosed, data);
- if ($button.hasClass('btn-close-anyway')) {
- warningBanner.addClass('hidden');
- if (this.closeReopenReportToggle)
- $('.js-issuable-close-dropdown').removeClass('hidden');
- }
- })
- .catch(() => flash(issueFailMessage))
- .then(() => {
- this.disableCloseReopenButton($button, false);
- });
- }
- },
- );
- }
-
- initCloseReopenReport() {
- this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
-
- if (this.closeButtons) this.closeButtons = this.closeButtons.not('.issuable-close-button');
- if (this.reopenButtons) this.reopenButtons = this.reopenButtons.not('.issuable-close-button');
- }
-
- disableCloseReopenButton($button, shouldDisable) {
- if (this.closeReopenReportToggle) {
- this.closeReopenReportToggle.setDisable(shouldDisable);
- } else {
- $button.prop('disabled', shouldDisable);
- }
- }
-
- toggleCloseReopenButton(isClosed) {
- if (this.closeReopenReportToggle) this.closeReopenReportToggle.updateButton(isClosed);
- this.closeButtons.toggleClass('hidden', isClosed);
- this.reopenButtons.toggleClass('hidden', !isClosed);
- }
-
- toggleWarningAndCloseButton() {
- const warningBanner = $('.js-close-blocked-issue-warning');
- warningBanner.toggleClass('hidden');
- $('.btn-close').toggleClass('hidden');
- if (this.closeReopenReportToggle) {
- $('.js-issuable-close-dropdown').toggleClass('hidden');
- }
- }
+ static initIssueMovedFromServiceDeskDismissHandler() {
+ const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning');
- initIssueWarningBtnEventListener() {
- return $(document).on(
- 'click',
- '.js-close-blocked-issue-warning .js-cancel-blocked-issue-warning',
- e => {
- e.preventDefault();
- e.stopImmediatePropagation();
- this.toggleWarningAndCloseButton();
- },
+ const trimmedPathname = window.location.pathname.slice(1);
+ const alertMovedFromServiceDeskDismissedKey = joinPaths(
+ trimmedPathname,
+ 'alert-issue-moved-from-service-desk-dismissed',
);
- }
- initIssueMovedFromServiceDeskDismissHandler() {
- const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning');
-
- if (!localStorage.getItem(this.alertMovedFromServiceDeskDismissedKey)) {
+ if (!localStorage.getItem(alertMovedFromServiceDeskDismissedKey)) {
alertMovedFromServiceDeskWarning.show();
}
@@ -196,20 +88,13 @@ export default class Issue {
e.preventDefault();
e.stopImmediatePropagation();
alertMovedFromServiceDeskWarning.remove();
- localStorage.setItem(this.alertMovedFromServiceDeskDismissedKey, true);
+ localStorage.setItem(alertMovedFromServiceDeskDismissedKey, true);
});
}
- static submitNoteForm(form) {
- const noteText = form.find('textarea.js-note-text').val();
- if (noteText && noteText.trim().length > 0) {
- return form.submit();
- }
- }
-
static initRelatedBranches() {
const $container = $('#related-branches');
- return axios
+ axios
.get($container.data('url'))
.then(({ data }) => {
if ('html' in data) {
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index 8a1a8448bb8..ea6e03404e7 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -61,11 +61,7 @@ export default {
data-toggle="dropdown"
>
<span class="dropdown-toggle-text">{{ __('Choose a template') }}</span>
- <gl-icon
- name="chevron-down"
- class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
- aria-hidden="true"
- />
+ <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" />
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title gl-display-flex gl-justify-content-center">
@@ -75,7 +71,7 @@ export default {
:aria-label="__('Close')"
type="button"
>
- <gl-icon name="close" class="dropdown-menu-close-icon" :aria-hidden="true" />
+ <gl-icon name="close" class="dropdown-menu-close-icon" />
</button>
</div>
<div class="dropdown-input">
@@ -85,7 +81,7 @@ export default {
:placeholder="__('Filter')"
autocomplete="off"
/>
- <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" />
+ <gl-icon name="search" class="dropdown-input-search" />
<gl-icon
name="close"
class="dropdown-input-clear js-dropdown-input-clear"
diff --git a/app/assets/javascripts/issue_show/components/header_actions.vue b/app/assets/javascripts/issue_show/components/header_actions.vue
index 4c8c86390f4..998f740be0e 100644
--- a/app/assets/javascripts/issue_show/components/header_actions.vue
+++ b/app/assets/javascripts/issue_show/components/header_actions.vue
@@ -1,12 +1,13 @@
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import createFlash, { FLASH_TYPES } from '~/flash';
import { IssuableType } from '~/issuable_show/constants';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { visitUrl } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
+import eventHub from '~/notes/event_hub';
import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
@@ -72,15 +73,11 @@ export default {
default: '',
},
},
- data() {
- return {
- isUpdatingState: false,
- };
- },
computed: {
- ...mapGetters(['getNoteableData']),
+ ...mapState(['isToggleStateButtonLoading']),
+ ...mapGetters(['openState', 'getBlockedByIssues']),
isClosed() {
- return this.getNoteableData.state === IssuableStatus.Closed;
+ return this.openState === IssuableStatus.Closed;
},
buttonText() {
return this.isClosed
@@ -107,9 +104,16 @@ export default {
return canClose || canReopen;
},
},
+ created() {
+ eventHub.$on('toggle.issuable.state', this.toggleIssueState);
+ },
+ beforeDestroy() {
+ eventHub.$off('toggle.issuable.state', this.toggleIssueState);
+ },
methods: {
+ ...mapActions(['toggleStateButtonLoading']),
toggleIssueState() {
- if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) {
+ if (!this.isClosed && this.getBlockedByIssues?.length) {
this.$refs.blockedByIssuesModal.show();
return;
}
@@ -117,7 +121,7 @@ export default {
this.invokeUpdateIssueMutation();
},
invokeUpdateIssueMutation() {
- this.isUpdatingState = true;
+ this.toggleStateButtonLoading(true);
this.$apollo
.mutate({
@@ -146,13 +150,13 @@ export default {
// Dispatch event which updates open/close state, shared among the issue show page
document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload));
})
- .catch(() => createFlash({ message: __('Update failed. Please try again.') }))
+ .catch(() => createFlash({ message: __('Error occurred while updating the issue status') }))
.finally(() => {
- this.isUpdatingState = false;
+ this.toggleStateButtonLoading(false);
});
},
promoteToEpic() {
- this.isUpdatingState = true;
+ this.toggleStateButtonLoading(true);
this.$apollo
.mutate({
@@ -179,7 +183,7 @@ export default {
})
.catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage }))
.finally(() => {
- this.isUpdatingState = false;
+ this.toggleStateButtonLoading(false);
});
},
},
@@ -188,18 +192,19 @@ export default {
<template>
<div class="detail-page-header-actions">
- <gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="dropdownText">
- <gl-dropdown-item
- v-if="showToggleIssueStateButton"
- :disabled="isUpdatingState"
- @click="toggleIssueState"
- >
+ <gl-dropdown
+ class="gl-display-block gl-display-sm-none!"
+ block
+ :text="dropdownText"
+ :loading="isToggleStateButtonLoading"
+ >
+ <gl-dropdown-item v-if="showToggleIssueStateButton" @click="toggleIssueState">
{{ buttonText }}
</gl-dropdown-item>
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ newIssueTypeText }}
</gl-dropdown-item>
- <gl-dropdown-item v-if="canPromoteToEpic" :disabled="isUpdatingState" @click="promoteToEpic">
+ <gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic">
{{ __('Promote to epic') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
@@ -220,7 +225,7 @@ export default {
class="gl-display-none gl-display-sm-inline-flex!"
category="secondary"
:data-qa-selector="qaSelector"
- :loading="isUpdatingState"
+ :loading="isToggleStateButtonLoading"
:variant="buttonVariant"
@click="toggleIssueState"
>
@@ -234,7 +239,7 @@ export default {
right
>
<template #button-content>
- <gl-icon name="ellipsis_v" aria-hidden="true" />
+ <gl-icon name="ellipsis_v" />
<span class="gl-sr-only">{{ dropdownText }}</span>
</template>
@@ -243,7 +248,7 @@ export default {
</gl-dropdown-item>
<gl-dropdown-item
v-if="canPromoteToEpic"
- :disabled="isUpdatingState"
+ :disabled="isToggleStateButtonLoading"
data-testid="promote-button"
@click="promoteToEpic"
>
@@ -272,7 +277,7 @@ export default {
>
<p>{{ __('This issue is currently blocked by the following issues:') }}</p>
<ul>
- <li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid">
+ <li v-for="issue in getBlockedByIssues" :key="issue.iid">
<gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link>
</li>
</ul>
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js
index 620974901fb..12f38005366 100644
--- a/app/assets/javascripts/issue_show/utils/parse_data.js
+++ b/app/assets/javascripts/issue_show/utils/parse_data.js
@@ -4,13 +4,11 @@ import { sanitize } from '~/lib/dompurify';
// We currently load + parse the data from the issue app and related merge request
let cachedParsedData;
-export const parseIssuableData = () => {
+export const parseIssuableData = el => {
try {
if (cachedParsedData) return cachedParsedData;
- const initialDataEl = document.getElementById('js-issuable-app');
-
- const parsedData = JSON.parse(initialDataEl.dataset.initial);
+ const parsedData = JSON.parse(el.dataset.initial);
parsedData.initialTitleHtml = sanitize(parsedData.initialTitleHtml);
parsedData.initialDescriptionHtml = sanitize(parsedData.initialDescriptionHtml);
@@ -23,5 +21,3 @@ export const parseIssuableData = () => {
return {};
}
};
-
-export default {};
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue
index b12b20d0135..16f8e67cde0 100644
--- a/app/assets/javascripts/issues_list/components/issuable.vue
+++ b/app/assets/javascripts/issues_list/components/issuable.vue
@@ -35,6 +35,7 @@ export default {
i18n: {
openedAgo: __('opened %{timeAgoString} by %{user}'),
openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'),
+ openedAgoServiceDesk: __('opened %{timeAgoString} by %{email} via %{user}'),
},
inject: ['scopedLabelsAvailable'],
components: {
@@ -206,6 +207,11 @@ export default {
healthStatus() {
return convertToCamelCase(this.issuable.health_status);
},
+ openedMessage() {
+ if (this.isJiraIssue) return this.$options.i18n.openedAgoJira;
+ if (this.issuable.service_desk_reply_to) return this.$options.i18n.openedAgoServiceDesk;
+ return this.$options.i18n.openedAgo;
+ },
},
mounted() {
// TODO: Refactor user popover to use its own component instead of
@@ -311,9 +317,7 @@ export default {
<span data-testid="openedByMessage" class="gl-display-none d-sm-inline-block gl-mr-4">
&middot;
- <gl-sprintf
- :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo"
- >
+ <gl-sprintf :message="openedMessage">
<template #timeAgoString>
<span>{{ issuableCreatedAt }}</span>
</template>
@@ -326,6 +330,9 @@ export default {
>{{ issuableAuthor.name }}</gl-link
>
</template>
+ <template #email>
+ <span>{{ issuable.service_desk_reply_to }}</span>
+ </template>
</gl-sprintf>
</span>
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 0d4f5bce965..0ce2bcc1cce 100644
--- a/app/assets/javascripts/issues_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issuables_list_app.vue
@@ -215,6 +215,7 @@ export default {
this.fetchIssuables();
},
beforeDestroy() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
issueableEventHub.$off('issuables:toggleBulkEdit');
},
methods: {
diff --git a/app/assets/javascripts/jira_connect.js b/app/assets/javascripts/jira_connect.js
deleted file mode 100644
index 0864a3024ac..00000000000
--- a/app/assets/javascripts/jira_connect.js
+++ /dev/null
@@ -1,63 +0,0 @@
-/* eslint-disable func-names, no-var, no-alert */
-/* global $ */
-/* global AP */
-
-/**
- * This script is not going through Webpack bundling
- * as it is only included in `app/views/jira_connect/subscriptions/index.html.haml`
- * which is going to be rendered within iframe on Jira app dashboard
- * hence any code written here needs to be IE11+ compatible (no fully ES6)
- */
-
-function onLoaded() {
- var reqComplete = function() {
- AP.navigator.reload();
- };
-
- var reqFailed = function(res) {
- alert(res.responseJSON.error);
- };
-
- AP.getLocation(function(location) {
- $('.js-jira-connect-sign-in').each(function() {
- var updatedLink = `${$(this).attr('href')}?return_to=${location}`;
- $(this).attr('href', updatedLink);
- });
- });
-
- $('#add-subscription-form').on('submit', function(e) {
- var actionUrl = $(this).attr('action');
- e.preventDefault();
-
- AP.context.getToken(function(token) {
- // eslint-disable-next-line no-jquery/no-ajax
- $.post(actionUrl, {
- jwt: token,
- namespace_path: $('#namespace-input').val(),
- format: 'json',
- })
- .done(reqComplete)
- .fail(reqFailed);
- });
- });
-
- $('.remove-subscription').on('click', function(e) {
- var href = $(this).attr('href');
- e.preventDefault();
-
- AP.context.getToken(function(token) {
- // eslint-disable-next-line no-jquery/no-ajax
- $.ajax({
- url: href,
- method: 'DELETE',
- data: {
- jwt: token,
- format: 'json',
- },
- })
- .done(reqComplete)
- .fail(reqFailed);
- });
- });
-}
-document.addEventListener('DOMContentLoaded', onLoaded);
diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue
index 6d32ba41eae..490bf2fdd66 100644
--- a/app/assets/javascripts/jira_connect/components/app.vue
+++ b/app/assets/javascripts/jira_connect/components/app.vue
@@ -1,7 +1,16 @@
<script>
-export default {};
+export default {
+ name: 'JiraConnectApp',
+ computed: {
+ state() {
+ return this.$root.$data.state || {};
+ },
+ error() {
+ return this.state.error;
+ },
+ },
+};
</script>
-
<template>
<div></div>
</template>
diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js
index 37f00d56a05..e7aa4c437bb 100644
--- a/app/assets/javascripts/jira_connect/index.js
+++ b/app/assets/javascripts/jira_connect/index.js
@@ -1,11 +1,85 @@
import Vue from 'vue';
+import $ from 'jquery';
import App from './components/app.vue';
+const store = {
+ state: {
+ error: '',
+ },
+ setErrorMessage(errorMessage) {
+ this.state.error = errorMessage;
+ },
+};
+
+/**
+ * Initialize necessary form handlers for the Jira Connect app
+ */
+const initJiraFormHandlers = () => {
+ const reqComplete = () => {
+ AP.navigator.reload();
+ };
+
+ const reqFailed = (res, fallbackErrorMessage) => {
+ const { responseJSON: { error = fallbackErrorMessage } = {} } = res || {};
+
+ store.setErrorMessage(error);
+ // eslint-disable-next-line no-alert
+ alert(error);
+ };
+
+ AP.getLocation(location => {
+ $('.js-jira-connect-sign-in').each(function updateSignInLink() {
+ const updatedLink = `${$(this).attr('href')}?return_to=${location}`;
+ $(this).attr('href', updatedLink);
+ });
+ });
+
+ $('#add-subscription-form').on('submit', function onAddSubscriptionForm(e) {
+ const actionUrl = $(this).attr('action');
+ e.preventDefault();
+
+ AP.context.getToken(token => {
+ // eslint-disable-next-line no-jquery/no-ajax
+ $.post(actionUrl, {
+ jwt: token,
+ namespace_path: $('#namespace-input').val(),
+ format: 'json',
+ })
+ .done(reqComplete)
+ .fail(err => reqFailed(err, 'Failed to add namespace. Please try again.'));
+ });
+ });
+
+ $('.remove-subscription').on('click', function onRemoveSubscriptionClick(e) {
+ const href = $(this).attr('href');
+ e.preventDefault();
+
+ AP.context.getToken(token => {
+ // eslint-disable-next-line no-jquery/no-ajax
+ $.ajax({
+ url: href,
+ method: 'DELETE',
+ data: {
+ jwt: token,
+ format: 'json',
+ },
+ })
+ .done(reqComplete)
+ .fail(err => reqFailed(err, 'Failed to remove namespace. Please try again.'));
+ });
+ });
+};
+
function initJiraConnect() {
const el = document.querySelector('.js-jira-connect-app');
+ initJiraFormHandlers();
+
return new Vue({
el,
+ data: {
+ state: store.state,
+ },
render(createElement) {
return createElement(App, {});
},
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index c6adf2f231f..30093224631 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -1,12 +1,11 @@
<script>
import { throttle, isEmpty } from 'lodash';
import { mapGetters, mapState, mapActions } from 'vuex';
-import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon, GlSafeHtmlDirective as SafeHtml, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
import { polyfillSticky } from '~/lib/utils/sticky';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
-import Callout from '~/vue_shared/components/callout.vue';
import EmptyState from './empty_state.vue';
import EnvironmentsBlock from './environments_block.vue';
import ErasedBlock from './erased_block.vue';
@@ -22,7 +21,6 @@ export default {
name: 'JobPageApp',
components: {
CiHeader,
- Callout,
EmptyState,
EnvironmentsBlock,
ErasedBlock,
@@ -34,6 +32,7 @@ export default {
Sidebar,
GlLoadingIcon,
SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'),
+ GlAlert,
},
directives: {
SafeHtml,
@@ -223,10 +222,14 @@ export default {
@clickedSidebarButton="toggleSidebar"
/>
</div>
-
- <callout v-if="shouldRenderHeaderCallout">
+ <gl-alert
+ v-if="shouldRenderHeaderCallout"
+ variant="danger"
+ class="gl-mt-3"
+ :dismissible="false"
+ >
<div v-safe-html="job.callout_message"></div>
- </callout>
+ </gl-alert>
</header>
<!-- EO Header Section -->
diff --git a/app/assets/javascripts/jobs/components/log/line.vue b/app/assets/javascripts/jobs/components/log/line.vue
index affaddcdee2..87af387ca91 100644
--- a/app/assets/javascripts/jobs/components/log/line.vue
+++ b/app/assets/javascripts/jobs/components/log/line.vue
@@ -18,46 +18,33 @@ export default {
render(h, { props }) {
const { line, path } = props;
- let chars;
- if (gon?.features?.ciJobLineLinks) {
- chars = line.content.map(content => {
- return h(
- 'span',
- {
- class: ['gl-white-space-pre-wrap', content.style],
- },
- // Simple "tokenization": Split text in chunks of text
- // which alternate between text and urls.
- content.text.split(linkRegex).map(chunk => {
- // Return normal string for non-links
- if (!chunk.match(linkRegex)) {
- return chunk;
- }
- return h(
- 'a',
- {
- attrs: {
- href: chunk,
- class: 'gl-reset-color! gl-text-decoration-underline',
- rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings
- },
+ const chars = line.content.map(content => {
+ return h(
+ 'span',
+ {
+ class: ['gl-white-space-pre-wrap', content.style],
+ },
+ // Simple "tokenization": Split text in chunks of text
+ // which alternate between text and urls.
+ content.text.split(linkRegex).map(chunk => {
+ // Return normal string for non-links
+ if (!chunk.match(linkRegex)) {
+ return chunk;
+ }
+ return h(
+ 'a',
+ {
+ attrs: {
+ href: chunk,
+ class: 'gl-reset-color! gl-text-decoration-underline',
+ rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings
},
- chunk,
- );
- }),
- );
- });
- } else {
- chars = line.content.map(content => {
- return h(
- 'span',
- {
- class: ['gl-white-space-pre-wrap', content.style],
- },
- content.text,
- );
- });
- }
+ },
+ chunk,
+ );
+ }),
+ );
+ });
return h('div', { class: 'js-line log-line' }, [
h(LineNumber, {
diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
index 633561c879e..c9747ca9f02 100644
--- a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
+++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
@@ -1,11 +1,19 @@
<script>
-import { GlLink } from '@gitlab/ui';
+import { GlLink, GlAlert } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
/**
* Renders Unmet Prerequisites block for job's view.
*/
export default {
+ i18n: {
+ failMessage: s__(
+ 'Job|This job failed because the necessary resources were not successfully created.',
+ ),
+ moreInformation: __('More information'),
+ },
components: {
GlLink,
+ GlAlert,
},
props: {
helpPath: {
@@ -16,15 +24,10 @@ export default {
};
</script>
<template>
- <div class="bs-callout bs-callout-danger">
- <p class="js-failed-unmet-prerequisites gl-mb-0">
- {{
- s__(`Job|This job failed because the necessary resources were not successfully created.`)
- }}
-
- <gl-link :href="helpPath" class="js-help-path">
- <strong> {{ __('More information') }} </strong>
- </gl-link>
- </p>
- </div>
+ <gl-alert variant="danger" class="gl-mt-3" :dismissible="false">
+ {{ $options.i18n.failMessage }}
+ <gl-link :href="helpPath">
+ {{ $options.i18n.moreInformation }}
+ </gl-link>
+ </gl-alert>
</template>
diff --git a/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js b/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js
index 8c7fb785a61..7b17dc7f693 100644
--- a/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js
+++ b/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js
@@ -20,7 +20,10 @@ export default {
computed: {
isDelayedJob() {
- return this.job && this.job.scheduled;
+ return this.job?.scheduled || this.job?.scheduledAt;
+ },
+ scheduledTime() {
+ return this.job.scheduled_at || this.job.scheduledAt;
},
},
@@ -43,7 +46,7 @@ export default {
},
updateRemainingTime() {
- const remainingMilliseconds = calculateRemainingMilliseconds(this.job.scheduled_at);
+ const remainingMilliseconds = calculateRemainingMilliseconds(this.scheduledTime);
this.remainingTime = formatTime(remainingMilliseconds);
},
},
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 1e4b5e986db..cac9dc06284 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -13,6 +13,7 @@ import {
scrollDown,
scrollUp,
} from '~/lib/utils/scroll_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
export const init = ({ dispatch }, { endpoint, logState, pagePath }) => {
dispatch('setJobEndpoint', endpoint);
@@ -20,8 +21,7 @@ export const init = ({ dispatch }, { endpoint, logState, pagePath }) => {
logState,
pagePath,
});
-
- return Promise.all([dispatch('fetchJob'), dispatch('fetchTrace')]);
+ dispatch('fetchJob');
};
export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint);
@@ -39,6 +39,7 @@ export const toggleSidebar = ({ dispatch, state }) => {
};
let eTagPoll;
+let isTraceReadyForRender;
export const clearEtagPoll = () => {
eTagPoll = null;
@@ -70,7 +71,14 @@ export const fetchJob = ({ state, dispatch }) => {
});
if (!Visibility.hidden()) {
- eTagPoll.makeRequest();
+ // eslint-disable-next-line promise/catch-or-return
+ eTagPoll.makeRequest().then(() => {
+ // if a job is canceled we still need to dispatch
+ // fetchTrace to get the trace so we check for has_trace
+ if (state.job.started || state.job.has_trace) {
+ dispatch('fetchTrace');
+ }
+ });
} else {
axios
.get(state.jobEndpoint)
@@ -80,9 +88,15 @@ export const fetchJob = ({ state, dispatch }) => {
Visibility.change(() => {
if (!Visibility.hidden()) {
+ // This check is needed to ensure the loading icon
+ // is not shown for a finished job during a visibility change
+ if (!isTraceReadyForRender && state.job.started) {
+ dispatch('startPollingTrace');
+ }
dispatch('restartPolling');
} else {
dispatch('stopPolling');
+ dispatch('stopPollingTrace');
}
});
};
@@ -163,6 +177,8 @@ export const fetchTrace = ({ dispatch, state }) =>
params: { state: state.traceState },
})
.then(({ data }) => {
+ isTraceReadyForRender = data.complete;
+
dispatch('toggleScrollisInBottom', isScrolledToBottom());
dispatch('receiveTraceSuccess', data);
@@ -172,7 +188,11 @@ export const fetchTrace = ({ dispatch, state }) =>
dispatch('startPollingTrace');
}
})
- .catch(() => dispatch('receiveTraceError'));
+ .catch(e =>
+ e.response.status === httpStatusCodes.FORBIDDEN
+ ? dispatch('receiveTraceUnauthorizedError')
+ : dispatch('receiveTraceError'),
+ );
export const startPollingTrace = ({ dispatch, commit }) => {
const traceTimeout = setTimeout(() => {
@@ -194,6 +214,10 @@ export const receiveTraceError = ({ dispatch }) => {
dispatch('stopPollingTrace');
flash(__('An error occurred while fetching the job log.'));
};
+export const receiveTraceUnauthorizedError = ({ dispatch }) => {
+ dispatch('stopPollingTrace');
+ flash(__('The current user is not authorized to access the job log.'));
+};
/**
* When the user clicks a collapsible line in the job
* log, we commit a mutation to update the state
@@ -234,7 +258,7 @@ export const receiveJobsForStageError = ({ commit }) => {
flash(__('An error occurred while fetching the jobs.'));
};
-export const triggerManualJob = ({ state }, variables) => {
+export const triggerManualJob = ({ state, dispatch }, variables) => {
const parsedVariables = variables.map(variable => {
const copyVar = { ...variable };
delete copyVar.id;
@@ -245,5 +269,6 @@ export const triggerManualJob = ({ state }, variables) => {
.post(state.job.status.action.path, {
job_variables_attributes: parsedVariables,
})
+ .then(() => dispatch('fetchTrace'))
.catch(() => flash(__('An error occurred while triggering the job.')));
};
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index 924b811d0d6..dea53f715a7 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -49,6 +49,7 @@ export default {
[types.SET_TRACE_TIMEOUT](state, id) {
state.traceTimeout = id;
+ state.isTraceComplete = false;
},
/**
diff --git a/app/assets/javascripts/jobs/utils.js b/app/assets/javascripts/jobs/utils.js
index 28a125b2b8f..122f23a5bb5 100644
--- a/app/assets/javascripts/jobs/utils.js
+++ b/app/assets/javascripts/jobs/utils.js
@@ -1,4 +1,12 @@
-// capture anything starting with http:// or https://
-// up until a disallowed character or whitespace
-export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+)/g;
+/**
+ * capture anything starting with http:// or https://
+ * https?:\/\/
+ *
+ * up until a disallowed character or whitespace
+ * [^"<>\\^`{|}\s]+
+ *
+ * and a disallowed character or whitespace, including non-ending chars .,:;!?
+ * [^"<>\\^`{|}\s.,:;!?]
+ */
+export const linkRegex = /(https?:\/\/[^"<>\\^`{|}\s]+[^"<>\\^`{|}\s.,:;!?])/g;
export default { linkRegex };
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 8bbd4300c96..ac5aa24d5d8 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -45,8 +45,7 @@ export default class LabelsSelect {
const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
const $value = $block.find('.value');
const $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
- // eslint-disable-next-line no-jquery/no-fade
- const $loading = $block.find('.block-loading').fadeOut();
+ const $loading = $block.find('.block-loading').addClass('gl-display-none');
const fieldName = $dropdown.data('fieldName');
let initialSelected = $selectbox
.find(`input[name="${$dropdown.data('fieldName')}"]`)
@@ -83,15 +82,13 @@ export default class LabelsSelect {
if (!selected.length) {
data[abilityName].label_ids = [''];
}
- // eslint-disable-next-line no-jquery/no-fade
- $loading.removeClass('hidden').fadeIn();
+ $loading.removeClass('gl-display-none');
$dropdown.trigger('loading.gl.dropdown');
axios
.put(issueUpdateURL, data)
.then(({ data }) => {
let template;
- // eslint-disable-next-line no-jquery/no-fade
- $loading.fadeOut();
+ $loading.addClass('gl-display-none');
$dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide();
data.issueUpdateURL = issueUpdateURL;
@@ -340,9 +337,8 @@ export default class LabelsSelect {
const { $el, e, isMarking } = clickEvent;
const label = clickEvent.selectedObj;
- const fadeOutLoader = () => {
- // eslint-disable-next-line no-jquery/no-fade
- $loading.fadeOut();
+ const hideLoader = () => {
+ $loading.addClass('gl-display-none');
};
const page = $('body').attr('data-page');
@@ -403,8 +399,7 @@ export default class LabelsSelect {
boardsStore.detail.issue.labels = labels;
}
- // eslint-disable-next-line no-jquery/no-fade
- $loading.fadeIn();
+ $loading.removeClass('gl-display-none');
const oldLabels = boardsStore.detail.issue.labels;
boardsStore.detail.issue
@@ -420,8 +415,8 @@ export default class LabelsSelect {
.removeClass('is-active');
}
})
- .then(fadeOutLoader)
- .catch(fadeOutLoader);
+ .then(hideLoader)
+ .catch(hideLoader);
} else if (handleClick) {
e.preventDefault();
handleClick(label);
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index 4d2955a8d3d..ab83f1ecc14 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import ContextualSidebar from './contextual_sidebar';
import initFlyOutNav from './fly_out_nav';
+import { setNotification } from './whats_new/utils/notification';
function hideEndFade($scrollingTabs) {
$scrollingTabs.each(function scrollTabsLoop() {
@@ -14,25 +15,17 @@ function hideEndFade($scrollingTabs) {
function initDeferred() {
$(document).trigger('init.scrolling-tabs');
- const whatsNewTriggerEl = document.querySelector('.js-whats-new-trigger');
- if (whatsNewTriggerEl) {
- const storageKey = whatsNewTriggerEl.getAttribute('data-storage-key');
+ const appEl = document.getElementById('whats-new-app');
+ if (!appEl) return;
- $('.header-help').on('show.bs.dropdown', () => {
- const displayNotification = JSON.parse(localStorage.getItem(storageKey));
- if (displayNotification === false) {
- $('.js-whats-new-notification-count').remove();
- }
- });
-
- whatsNewTriggerEl.addEventListener('click', () => {
- import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new')
- .then(({ default: initWhatsNew }) => {
- initWhatsNew();
- })
- .catch(() => {});
- });
- }
+ setNotification(appEl);
+ document.querySelector('.js-whats-new-trigger').addEventListener('click', () => {
+ import(/* webpackChunkName: 'whatsNewApp' */ '~/whats_new')
+ .then(({ default: initWhatsNew }) => {
+ initWhatsNew(appEl);
+ })
+ .catch(() => {});
+ });
}
export default function initLayoutNav() {
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 42a5de68cfa..f88a0433535 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -218,23 +218,46 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
export const contentTop = () => {
- const perfBar = $('#js-peek').outerHeight() || 0;
- const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0;
- const headerHeight = $('.navbar-gitlab').outerHeight() || 0;
- const diffFilesChanged = $('.js-diff-files-changed').outerHeight() || 0;
const isDesktop = breakpointInstance.isDesktop();
- const diffFileTitleBar =
- (isDesktop && $('.diff-file .file-title-flex-parent:visible').outerHeight()) || 0;
- const compareVersionsHeaderHeight = (isDesktop && $('.mr-version-controls').outerHeight()) || 0;
+ const heightCalculators = [
+ () => $('#js-peek').outerHeight(),
+ () => $('.navbar-gitlab').outerHeight(),
+ ({ desktop }) => {
+ const container = document.querySelector('.line-resolve-all-container');
+ let size = 0;
+
+ if (!desktop && container) {
+ size = container.offsetHeight;
+ }
- return (
- perfBar +
- mrTabsHeight +
- headerHeight +
- diffFilesChanged +
- diffFileTitleBar +
- compareVersionsHeaderHeight
- );
+ return size;
+ },
+ () => $('.merge-request-tabs').outerHeight(),
+ () => $('.js-diff-files-changed').outerHeight(),
+ ({ desktop }) => {
+ const diffsTabIsActive = window.mrTabs?.currentAction === 'diffs';
+ let size;
+
+ if (desktop && diffsTabIsActive) {
+ size = $('.diff-file .file-title-flex-parent:visible').outerHeight();
+ }
+
+ return size;
+ },
+ ({ desktop }) => {
+ let size;
+
+ if (desktop) {
+ size = $('.mr-version-controls').outerHeight();
+ }
+
+ return size;
+ },
+ ];
+
+ return heightCalculators.reduce((totalHeight, calculator) => {
+ return totalHeight + (calculator({ desktop: isDesktop }) || 0);
+ }, 0);
};
export const scrollToElement = (element, options = {}) => {
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index 7bba7ba2f45..2f19a0c9b26 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -1,6 +1,14 @@
import { has } from 'lodash';
import { isInIssuePage, isInMRPage, isInEpicPage } from './common_utils';
+/**
+ * Checks whether an element's content exceeds the element's width.
+ *
+ * @param element DOM element to check
+ */
+export const hasHorizontalOverflow = element =>
+ Boolean(element && element.scrollWidth > element.offsetWidth);
+
export const addClassIfElementExists = (element, className) => {
if (element) {
element.classList.add(className);
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
index 618266f7a09..6f5cd7460f8 100644
--- a/app/assets/javascripts/lib/utils/keycodes.js
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -2,6 +2,7 @@
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/216102
export const BACKSPACE_KEY_CODE = 8;
+export const TAB_KEY_CODE = 9;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
export const UP_KEY_CODE = 38;
diff --git a/app/assets/javascripts/lib/utils/scroll_utils.js b/app/assets/javascripts/lib/utils/scroll_utils.js
index b4da1e16f08..01e43fd3b93 100644
--- a/app/assets/javascripts/lib/utils/scroll_utils.js
+++ b/app/assets/javascripts/lib/utils/scroll_utils.js
@@ -49,5 +49,3 @@ export const toggleDisableButton = ($button, disable) => {
if (disable && $button.prop('disabled')) return;
$button.prop('disabled', disable);
};
-
-export default {};
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index dfb86787788..c711c0bd163 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -339,6 +339,7 @@ export function addMarkdownListeners(form) {
Shortcuts.initMarkdownEditorShortcuts($(this), updateTextForToolbarBtn);
});
+ // eslint-disable-next-line @gitlab/no-global-event-off
const $allToolbarBtns = $('.js-md', form)
.off('click')
.on('click', function() {
@@ -351,6 +352,7 @@ export function addMarkdownListeners(form) {
}
export function addEditorMarkdownListeners(editor) {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.js-md')
.off('click')
.on('click', e => {
@@ -376,5 +378,6 @@ export function removeMarkdownListeners(form) {
Shortcuts.removeMarkdownEditorShortcuts($(this));
});
+ // eslint-disable-next-line @gitlab/no-global-event-off
return $('.js-md', form).off('click');
}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index a81ca3f211f..c398874db24 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -411,3 +411,13 @@ export const hasContent = obj => isString(obj) && obj.trim() !== '';
export const isValidSha1Hash = str => {
return /^[0-9a-f]{5,40}$/.test(str);
};
+
+/**
+ * Adds a final newline to the content if it doesn't already exist
+ *
+ * @param {*} content Content
+ * @param {*} endOfLine Type of newline: CRLF='\r\n', LF='\n', CR='\r'
+ */
+export function insertFinalNewline(content, endOfLine = '\n') {
+ return content.slice(-endOfLine.length) !== endOfLine ? `${content}${endOfLine}` : content;
+}
diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js
index 8e537a4025f..880f762e225 100644
--- a/app/assets/javascripts/logs/utils.js
+++ b/app/assets/javascripts/logs/utils.js
@@ -23,5 +23,3 @@ export const getTimeRange = (seconds = 0) => {
};
export const formatDate = timestamp => dateFormat(timestamp, dateFormatMask);
-
-export default {};
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index b404f390a2d..de7648c31b1 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -23,7 +23,6 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// everything else
import { deprecatedCreateFlash as Flash, removeFlashClickListener } from './flash';
import initTodoToggle from './header';
-import initImporterStatus from './importer_status';
import initLayoutNav from './layout_nav';
import initAlertHandler from './alert_handler';
import './feature_highlight/feature_highlight_options';
@@ -78,6 +77,7 @@ if (process.env.NODE_ENV !== 'production' && gon?.test_env) {
document.addEventListener('beforeunload', () => {
// Unbind scroll events
+ // eslint-disable-next-line @gitlab/no-global-event-off
$(document).off('scroll');
// Close any open tooltips
tooltips.dispose(document.querySelectorAll('.has-tooltip, [data-toggle="tooltip"]'));
@@ -107,7 +107,6 @@ function deferredInitialisation() {
const $body = $('body');
initBreadcrumbs();
- initImporterStatus();
initTodoToggle();
initLogoAnimation();
initUsagePingConsent();
@@ -138,10 +137,9 @@ function deferredInitialisation() {
$('.remove-row').on('ajax:success', function removeRowAjaxSuccessCallback() {
tooltips.dispose(this);
- // eslint-disable-next-line no-jquery/no-fade
$(this)
.closest('li')
- .fadeOut();
+ .addClass('gl-display-none!');
});
$('.js-remove-tr').on('ajax:before', function removeTRAjaxBeforeCallback() {
@@ -149,10 +147,9 @@ function deferredInitialisation() {
});
$('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() {
- // eslint-disable-next-line no-jquery/no-fade
$(this)
.closest('tr')
- .fadeOut();
+ .addClass('gl-display-none!');
});
const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
diff --git a/app/assets/javascripts/maintenance_mode_settings/components/app.vue b/app/assets/javascripts/maintenance_mode_settings/components/app.vue
deleted file mode 100644
index 11d154ed9d1..00000000000
--- a/app/assets/javascripts/maintenance_mode_settings/components/app.vue
+++ /dev/null
@@ -1,44 +0,0 @@
-<script>
-import { GlToggle, GlFormGroup, GlFormTextarea, GlButton } from '@gitlab/ui';
-
-export default {
- name: 'MaintenanceModeSettingsApp',
- components: {
- GlToggle,
- GlFormGroup,
- GlFormTextarea,
- GlButton,
- },
- data() {
- return {
- inMaintenanceMode: false,
- bannerMessage: '',
- };
- },
-};
-</script>
-<template>
- <article>
- <div class="d-flex align-items-center mb-3">
- <gl-toggle v-model="inMaintenanceMode" class="mb-0" />
- <div class="ml-2">
- <p class="mb-0">{{ __('Enable maintenance mode') }}</p>
- <p class="mb-0 text-secondary-500">
- {{
- __('Non-admin users can sign in with read-only access and make read-only API requests.')
- }}
- </p>
- </div>
- </div>
- <gl-form-group label="Banner Message" label-for="maintenanceBannerMessage">
- <gl-form-textarea
- id="maintenanceBannerMessage"
- v-model="bannerMessage"
- :placeholder="__(`GitLab is undergoing maintenance and is operating in a read-only mode.`)"
- />
- </gl-form-group>
- <div class="mt-4">
- <gl-button variant="success" category="primary">{{ __('Save changes') }}</gl-button>
- </div>
- </article>
-</template>
diff --git a/app/assets/javascripts/maintenance_mode_settings/index.js b/app/assets/javascripts/maintenance_mode_settings/index.js
deleted file mode 100644
index 7a80233faf0..00000000000
--- a/app/assets/javascripts/maintenance_mode_settings/index.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import MaintenanceModeSettingsApp from './components/app.vue';
-
-Vue.use(Translate);
-
-export default () => {
- const el = document.getElementById('js-maintenance-mode-settings');
-
- return new Vue({
- el,
- components: {
- MaintenanceModeSettingsApp,
- },
-
- render(createElement) {
- return createElement('maintenance-mode-settings-app');
- },
- });
-};
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index 6dd4018f87a..5bd228496da 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -11,9 +11,11 @@ export default class Members {
}
addListeners() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.js-member-update-control')
.off('change')
.on('change', this.formSubmit.bind(this));
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.js-edit-member-form')
.off('ajax:success')
.on('ajax:success', this.formSuccess.bind(this));
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
index 10078d5cd64..10078d5cd64 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/access_request_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue b/app/assets/javascripts/members/components/action_buttons/action_button_group.vue
index 8356fdb60b1..8356fdb60b1 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/action_button_group.vue
+++ b/app/assets/javascripts/members/components/action_buttons/action_button_group.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
index e8a53ff173d..e8a53ff173d 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/approve_access_request_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/approve_access_request_button.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue
index 2aebfe80db5..2aebfe80db5 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/group_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/group_action_buttons.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
index 2b0a75640e2..2b0a75640e2 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/invite_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue b/app/assets/javascripts/members/components/action_buttons/leave_button.vue
index d9976e7181c..443a962e0cf 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/leave_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/leave_button.vue
@@ -2,7 +2,7 @@
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import LeaveModal from '../modals/leave_modal.vue';
-import { LEAVE_MODAL_ID } from '../constants';
+import { LEAVE_MODAL_ID } from '../../constants';
export default {
name: 'LeaveButton',
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
index 9d89cb40676..9d89cb40676 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_group_link_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_group_link_button.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
index b0b7ff4ce9a..b0b7ff4ce9a 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/remove_member_button.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
index 1cc3fd17e98..1cc3fd17e98 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/resend_invite_button.vue
+++ b/app/assets/javascripts/members/components/action_buttons/resend_invite_button.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index 484dbb8fef5..f2bc9c7e876 100644
--- a/app/assets/javascripts/vue_shared/components/members/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -11,7 +11,7 @@ export default {
RemoveMemberButton,
LeaveButton,
LdapOverrideButton: () =>
- import('ee_component/vue_shared/components/members/ldap/ldap_override_button.vue'),
+ import('ee_component/members/components/ldap/ldap_override_button.vue'),
},
props: {
member: {
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue b/app/assets/javascripts/members/components/avatars/group_avatar.vue
index 12b748f9ab6..3b176bf2b43 100644
--- a/app/assets/javascripts/vue_shared/components/members/avatars/group_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/group_avatar.vue
@@ -1,6 +1,6 @@
<script>
import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui';
-import { AVATAR_SIZE } from '../constants';
+import { AVATAR_SIZE } from '../../constants';
export default {
name: 'GroupAvatar',
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue b/app/assets/javascripts/members/components/avatars/invite_avatar.vue
index 28654a60860..08e702007bb 100644
--- a/app/assets/javascripts/vue_shared/components/members/avatars/invite_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/invite_avatar.vue
@@ -1,6 +1,6 @@
<script>
import { GlAvatarLabeled } from '@gitlab/ui';
-import { AVATAR_SIZE } from '../constants';
+import { AVATAR_SIZE } from '../../constants';
export default {
name: 'InviteAvatar',
diff --git a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue b/app/assets/javascripts/members/components/avatars/user_avatar.vue
index e5e7cdf149c..fe45ca769af 100644
--- a/app/assets/javascripts/vue_shared/components/members/avatars/user_avatar.vue
+++ b/app/assets/javascripts/members/components/avatars/user_avatar.vue
@@ -5,9 +5,9 @@ import {
GlBadge,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
-import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils';
+import { generateBadges } from 'ee_else_ce/members/utils';
import { __ } from '~/locale';
-import { AVATAR_SIZE } from '../constants';
+import { AVATAR_SIZE } from '../../constants';
import { glEmojiTag } from '~/emoji';
export default {
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
new file mode 100644
index 00000000000..f869ecd392f
--- /dev/null
+++ b/app/assets/javascripts/members/components/filter_sort/filter_sort_container.vue
@@ -0,0 +1,26 @@
+<script>
+import { mapState } from 'vuex';
+import MembersFilteredSearchBar from './members_filtered_search_bar.vue';
+import SortDropdown from './sort_dropdown.vue';
+
+export default {
+ name: 'FilterSortContainer',
+ components: { MembersFilteredSearchBar, SortDropdown },
+ computed: {
+ ...mapState(['filteredSearchBar', 'tableSortableFields']),
+ showContainer() {
+ return this.filteredSearchBar.show || this.showSortDropdown;
+ },
+ showSortDropdown() {
+ return this.tableSortableFields.length;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="showContainer" class="gl-bg-gray-10 gl-p-3 gl-display-md-flex">
+ <members-filtered-search-bar v-if="filteredSearchBar.show" class="gl-p-3 gl-flex-grow-1" />
+ <sort-dropdown v-if="showSortDropdown" class="gl-p-3 gl-flex-shrink-0" />
+ </div>
+</template>
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
new file mode 100644
index 00000000000..c1df0b94234
--- /dev/null
+++ b/app/assets/javascripts/members/components/filter_sort/members_filtered_search_bar.vue
@@ -0,0 +1,132 @@
+<script>
+import { mapState } from 'vuex';
+import { GlFilteredSearchToken } from '@gitlab/ui';
+import { setUrlParams, queryToObject } from '~/lib/utils/url_utility';
+import { getParameterByName } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
+import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
+import { SEARCH_TOKEN_TYPE, SORT_PARAM } from '~/members/constants';
+
+export default {
+ name: 'MembersFilteredSearchBar',
+ components: { FilteredSearchBar },
+ availableTokens: [
+ {
+ type: 'two_factor',
+ icon: 'lock',
+ title: s__('Members|2FA'),
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: [{ value: '=', description: 'is' }],
+ options: [
+ { value: 'enabled', title: s__('Members|Enabled') },
+ { value: 'disabled', title: s__('Members|Disabled') },
+ ],
+ requiredPermissions: 'canManageMembers',
+ },
+ {
+ type: 'with_inherited_permissions',
+ icon: 'group',
+ title: s__('Members|Membership'),
+ token: GlFilteredSearchToken,
+ unique: true,
+ operators: [{ value: '=', description: 'is' }],
+ options: [
+ { value: 'exclude', title: s__('Members|Direct') },
+ { value: 'only', title: s__('Members|Inherited') },
+ ],
+ },
+ ],
+ data() {
+ return {
+ initialFilterValue: [],
+ };
+ },
+ computed: {
+ ...mapState(['sourceId', 'filteredSearchBar', 'canManageMembers']),
+ tokens() {
+ return this.$options.availableTokens.filter(token => {
+ if (
+ Object.prototype.hasOwnProperty.call(token, 'requiredPermissions') &&
+ !this[token.requiredPermissions]
+ ) {
+ return false;
+ }
+
+ return this.filteredSearchBar.tokens?.includes(token.type);
+ });
+ },
+ },
+ created() {
+ const query = queryToObject(window.location.search);
+
+ const tokens = this.tokens
+ .filter(token => query[token.type])
+ .map(token => ({
+ type: token.type,
+ value: {
+ data: query[token.type],
+ operator: '=',
+ },
+ }));
+
+ if (query[this.filteredSearchBar.searchParam]) {
+ tokens.push({
+ type: SEARCH_TOKEN_TYPE,
+ value: {
+ data: query[this.filteredSearchBar.searchParam],
+ },
+ });
+ }
+
+ this.initialFilterValue = tokens;
+ },
+ methods: {
+ handleFilter(tokens) {
+ const params = tokens.reduce((accumulator, token) => {
+ const { type, value } = token;
+
+ if (!type || !value) {
+ return accumulator;
+ }
+
+ if (type === SEARCH_TOKEN_TYPE) {
+ if (value.data !== '') {
+ return {
+ ...accumulator,
+ [this.filteredSearchBar.searchParam]: value.data,
+ };
+ }
+ } else {
+ return {
+ ...accumulator,
+ [type]: value.data,
+ };
+ }
+
+ return accumulator;
+ }, {});
+
+ const sortParam = getParameterByName(SORT_PARAM);
+
+ window.location.href = setUrlParams(
+ { ...params, ...(sortParam && { sort: sortParam }) },
+ window.location.href,
+ true,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <filtered-search-bar
+ :namespace="sourceId.toString()"
+ :tokens="tokens"
+ :recent-searches-storage-key="filteredSearchBar.recentSearchesStorageKey"
+ :search-input-placeholder="filteredSearchBar.placeholder"
+ :initial-filter-value="initialFilterValue"
+ data-testid="members-filtered-search-bar"
+ @onFilter="handleFilter"
+ />
+</template>
diff --git a/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
new file mode 100644
index 00000000000..de7fbc4241c
--- /dev/null
+++ b/app/assets/javascripts/members/components/filter_sort/sort_dropdown.vue
@@ -0,0 +1,77 @@
+<script>
+import { mapState } from 'vuex';
+import { GlSorting, GlSortingItem } from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { parseSortParam, buildSortHref } from '~/members/utils';
+import { FIELDS } from '~/members/constants';
+
+export default {
+ name: 'SortDropdown',
+ components: { GlSorting, GlSortingItem },
+ computed: {
+ ...mapState(['tableSortableFields', 'filteredSearchBar']),
+ sort() {
+ return parseSortParam(this.tableSortableFields);
+ },
+ activeOption() {
+ return FIELDS.find(field => field.key === this.sort.sortByKey);
+ },
+ activeOptionLabel() {
+ return this.activeOption?.label;
+ },
+ isAscending() {
+ return !this.sort.sortDesc;
+ },
+ filteredOptions() {
+ return FIELDS.filter(field => this.tableSortableFields.includes(field.key) && field.sort).map(
+ field => ({
+ key: field.key,
+ label: field.label,
+ href: buildSortHref({
+ sortBy: field.key,
+ sortDesc: false,
+ filteredSearchBarTokens: this.filteredSearchBar.tokens,
+ filteredSearchBarSearchParam: this.filteredSearchBar.searchParam,
+ }),
+ }),
+ );
+ },
+ },
+ methods: {
+ isActive(key) {
+ return this.activeOption.key === key;
+ },
+ handleSortDirectionChange() {
+ visitUrl(
+ buildSortHref({
+ sortBy: this.activeOption.key,
+ sortDesc: !this.sort.sortDesc,
+ filteredSearchBarTokens: this.filteredSearchBar.tokens,
+ filteredSearchBarSearchParam: this.filteredSearchBar.searchParam,
+ }),
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-sorting
+ class="gl-display-flex"
+ dropdown-class="gl-w-full"
+ data-testid="members-sort-dropdown"
+ :text="activeOptionLabel"
+ :is-ascending="isAscending"
+ :sort-direction-tool-tip="__('Sort direction')"
+ @sortDirectionChange="handleSortDirectionChange"
+ >
+ <gl-sorting-item
+ v-for="option in filteredOptions"
+ :key="option.key"
+ :href="option.href"
+ :active="isActive(option.key)"
+ >
+ {{ option.label }}
+ </gl-sorting-item>
+ </gl-sorting>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index 9a2ce0d4931..57a5da774e3 100644
--- a/app/assets/javascripts/vue_shared/components/members/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -3,7 +3,7 @@ import { mapState } from 'vuex';
import { GlModal, GlForm, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
-import { LEAVE_MODAL_ID } from '../constants';
+import { LEAVE_MODAL_ID } from '../../constants';
export default {
name: 'LeaveModal',
diff --git a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
index e8890717724..231d014a4ec 100644
--- a/app/assets/javascripts/vue_shared/components/members/modals/remove_group_link_modal.vue
+++ b/app/assets/javascripts/members/components/modals/remove_group_link_modal.vue
@@ -3,7 +3,7 @@ import { mapState, mapActions } from 'vuex';
import { GlModal, GlSprintf, GlForm } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { __, s__, sprintf } from '~/locale';
-import { REMOVE_GROUP_LINK_MODAL_ID } from '../constants';
+import { REMOVE_GROUP_LINK_MODAL_ID } from '../../constants';
export default {
name: 'RemoveGroupLinkModal',
diff --git a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue b/app/assets/javascripts/members/components/table/created_at.vue
index 0bad70894f9..0bad70894f9 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/created_at.vue
+++ b/app/assets/javascripts/members/components/table/created_at.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
index 0a8af81c1d1..0a8af81c1d1 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/expiration_datepicker.vue
+++ b/app/assets/javascripts/members/components/table/expiration_datepicker.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue b/app/assets/javascripts/members/components/table/expires_at.vue
index de65e3fb10f..c91de061b50 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/expires_at.vue
+++ b/app/assets/javascripts/members/components/table/expires_at.vue
@@ -6,7 +6,7 @@ import {
formatDate,
getDayDifference,
} from '~/lib/utils/datetime_utility';
-import { DAYS_TO_EXPIRE_SOON } from '../constants';
+import { DAYS_TO_EXPIRE_SOON } from '../../constants';
export default {
name: 'ExpiresAt',
diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue b/app/assets/javascripts/members/components/table/member_action_buttons.vue
index 320d8c99223..c61ebec33bd 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/member_action_buttons.vue
+++ b/app/assets/javascripts/members/components/table/member_action_buttons.vue
@@ -3,7 +3,7 @@ import UserActionButtons from '../action_buttons/user_action_buttons.vue';
import GroupActionButtons from '../action_buttons/group_action_buttons.vue';
import InviteActionButtons from '../action_buttons/invite_action_buttons.vue';
import AccessRequestActionButtons from '../action_buttons/access_request_action_buttons.vue';
-import { MEMBER_TYPES } from '../constants';
+import { MEMBER_TYPES } from '../../constants';
export default {
name: 'MemberActionButtons',
diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue b/app/assets/javascripts/members/components/table/member_avatar.vue
index a1f98d4008a..a1f98d4008a 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/member_avatar.vue
+++ b/app/assets/javascripts/members/components/table/member_avatar.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue
index 030d72c3420..030d72c3420 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/member_source.vue
+++ b/app/assets/javascripts/members/components/table/member_source.vue
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue
index a4f67caff31..da77e5caad2 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
+++ b/app/assets/javascripts/members/components/table/members_table.vue
@@ -1,14 +1,9 @@
<script>
import { mapState } from 'vuex';
import { GlTable, GlBadge } from '@gitlab/ui';
-import MembersTableCell from 'ee_else_ce/vue_shared/components/members/table/members_table_cell.vue';
-import {
- canOverride,
- canRemove,
- canResend,
- canUpdate,
-} from 'ee_else_ce/vue_shared/components/members/utils';
-import { FIELDS } from '../constants';
+import MembersTableCell from 'ee_else_ce/members/components/table/members_table_cell.vue';
+import { canOverride, canRemove, canResend, canUpdate } from 'ee_else_ce/members/utils';
+import { FIELDS } from '../../constants';
import initUserPopovers from '~/user_popovers';
import MemberAvatar from './member_avatar.vue';
import MemberSource from './member_source.vue';
@@ -34,9 +29,7 @@ export default {
RemoveGroupLinkModal,
ExpirationDatepicker,
LdapOverrideConfirmationModal: () =>
- import(
- 'ee_component/vue_shared/components/members/ldap/ldap_override_confirmation_modal.vue'
- ),
+ import('ee_component/members/components/ldap/ldap_override_confirmation_modal.vue'),
},
computed: {
...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']),
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue b/app/assets/javascripts/members/components/table/members_table_cell.vue
index 11e1aef9803..20aa01b96bc 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/members_table_cell.vue
+++ b/app/assets/javascripts/members/components/table/members_table_cell.vue
@@ -1,7 +1,14 @@
<script>
import { mapState } from 'vuex';
-import { MEMBER_TYPES } from '../constants';
-import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils';
+import { MEMBER_TYPES } from '../../constants';
+import {
+ isGroup,
+ isDirectMember,
+ isCurrentUser,
+ canRemove,
+ canResend,
+ canUpdate,
+} from '../../utils';
export default {
name: 'MembersTableCell',
diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue
index 6f6cae6072d..8ad45ab6920 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
+++ b/app/assets/javascripts/members/components/table/role_dropdown.vue
@@ -9,8 +9,7 @@ export default {
components: {
GlDropdown,
GlDropdownItem,
- LdapDropdownItem: () =>
- import('ee_component/vue_shared/components/members/ldap/ldap_dropdown_item.vue'),
+ LdapDropdownItem: () => import('ee_component/members/components/ldap/ldap_dropdown_item.vue'),
},
props: {
member: {
diff --git a/app/assets/javascripts/vue_shared/components/members/constants.js b/app/assets/javascripts/members/constants.js
index 5885420a122..21af825f795 100644
--- a/app/assets/javascripts/vue_shared/components/members/constants.js
+++ b/app/assets/javascripts/members/constants.js
@@ -4,6 +4,10 @@ export const FIELDS = [
{
key: 'account',
label: __('Account'),
+ sort: {
+ asc: 'name_asc',
+ desc: 'name_desc',
+ },
},
{
key: 'source',
@@ -16,6 +20,10 @@ export const FIELDS = [
label: __('Access granted'),
thClass: 'col-meta',
tdClass: 'col-meta',
+ sort: {
+ asc: 'last_joined',
+ desc: 'oldest_joined',
+ },
},
{
key: 'invited',
@@ -40,6 +48,10 @@ export const FIELDS = [
label: __('Max role'),
thClass: 'col-max-role',
tdClass: 'col-max-role',
+ sort: {
+ asc: 'access_level_asc',
+ desc: 'access_level_desc',
+ },
},
{
key: 'expiration',
@@ -48,6 +60,14 @@ export const FIELDS = [
tdClass: 'col-expiration',
},
{
+ key: 'lastSignIn',
+ label: __('Last sign-in'),
+ sort: {
+ asc: 'recent_sign_in',
+ desc: 'oldest_sign_in',
+ },
+ },
+ {
key: 'actions',
thClass: 'col-actions',
tdClass: 'col-actions',
@@ -55,6 +75,11 @@ export const FIELDS = [
},
];
+export const DEFAULT_SORT = {
+ sortByKey: 'account',
+ sortDesc: false,
+};
+
export const AVATAR_SIZE = 48;
export const MEMBER_TYPES = {
@@ -69,3 +94,7 @@ export const DAYS_TO_EXPIRE_SOON = 7;
export const LEAVE_MODAL_ID = 'member-leave-modal';
export const REMOVE_GROUP_LINK_MODAL_ID = 'remove-group-link-modal-id';
+
+export const SEARCH_TOKEN_TYPE = 'filtered-search-term';
+
+export const SORT_PARAM = 'sort';
diff --git a/app/assets/javascripts/vuex_shared/modules/members/actions.js b/app/assets/javascripts/members/store/actions.js
index 4c31b3c9744..4c31b3c9744 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/actions.js
+++ b/app/assets/javascripts/members/store/actions.js
diff --git a/app/assets/javascripts/members/store/index.js b/app/assets/javascripts/members/store/index.js
new file mode 100644
index 00000000000..f219f8931b0
--- /dev/null
+++ b/app/assets/javascripts/members/store/index.js
@@ -0,0 +1,9 @@
+import createState from 'ee_else_ce/members/store/state';
+import mutations from 'ee_else_ce/members/store/mutations';
+import * as actions from 'ee_else_ce/members/store/actions';
+
+export default initialState => ({
+ state: createState(initialState),
+ actions,
+ mutations,
+});
diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js b/app/assets/javascripts/members/store/mutation_types.js
index 77307aa745b..77307aa745b 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/mutation_types.js
+++ b/app/assets/javascripts/members/store/mutation_types.js
diff --git a/app/assets/javascripts/vuex_shared/modules/members/mutations.js b/app/assets/javascripts/members/store/mutations.js
index 2415e744290..2415e744290 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/mutations.js
+++ b/app/assets/javascripts/members/store/mutations.js
diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/members/store/state.js
index ab3ebb34616..23a7983adcc 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/state.js
+++ b/app/assets/javascripts/members/store/state.js
@@ -2,18 +2,24 @@ export default ({
members,
sourceId,
currentUserId,
+ canManageMembers,
tableFields,
tableAttrs,
+ tableSortableFields,
memberPath,
requestFormatter,
+ filteredSearchBar,
}) => ({
members,
sourceId,
currentUserId,
+ canManageMembers,
tableFields,
tableAttrs,
+ tableSortableFields,
memberPath,
requestFormatter,
+ filteredSearchBar,
showError: false,
errorMessage: '',
removeGroupLinkModalVisible: false,
diff --git a/app/assets/javascripts/vuex_shared/modules/members/utils.js b/app/assets/javascripts/members/store/utils.js
index 7dcd33111e8..7dcd33111e8 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/utils.js
+++ b/app/assets/javascripts/members/store/utils.js
diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js
new file mode 100644
index 00000000000..bf1fc2d7515
--- /dev/null
+++ b/app/assets/javascripts/members/utils.js
@@ -0,0 +1,97 @@
+import { __ } from '~/locale';
+import { getParameterByName } from '~/lib/utils/common_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import { FIELDS, DEFAULT_SORT } from './constants';
+
+export const generateBadges = (member, isCurrentUser) => [
+ {
+ show: isCurrentUser,
+ text: __("It's you"),
+ variant: 'success',
+ },
+ {
+ show: member.user?.blocked,
+ text: __('Blocked'),
+ variant: 'danger',
+ },
+ {
+ show: member.user?.twoFactorEnabled,
+ text: __('2FA'),
+ variant: 'info',
+ },
+];
+
+export const isGroup = member => {
+ return Boolean(member.sharedWithGroup);
+};
+
+export const isDirectMember = (member, sourceId) => {
+ return isGroup(member) || member.source?.id === sourceId;
+};
+
+export const isCurrentUser = (member, currentUserId) => {
+ return member.user?.id === currentUserId;
+};
+
+export const canRemove = (member, sourceId) => {
+ return isDirectMember(member, sourceId) && member.canRemove;
+};
+
+export const canResend = member => {
+ return Boolean(member.invite?.canResend);
+};
+
+export const canUpdate = (member, currentUserId, sourceId) => {
+ return (
+ !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate
+ );
+};
+
+export const parseSortParam = sortableFields => {
+ const sortParam = getParameterByName('sort');
+
+ const sortedField = FIELDS.filter(field => sortableFields.includes(field.key)).find(
+ field => field.sort?.asc === sortParam || field.sort?.desc === sortParam,
+ );
+
+ if (!sortedField) {
+ return DEFAULT_SORT;
+ }
+
+ return {
+ sortByKey: sortedField.key,
+ sortDesc: sortedField?.sort?.desc === sortParam,
+ };
+};
+
+export const buildSortHref = ({
+ sortBy,
+ sortDesc,
+ filteredSearchBarTokens,
+ filteredSearchBarSearchParam,
+}) => {
+ const sortDefinition = FIELDS.find(field => field.key === sortBy)?.sort;
+
+ if (!sortDefinition) {
+ return '';
+ }
+
+ const sortParam = sortDesc ? sortDefinition.desc : sortDefinition.asc;
+
+ const filterParams =
+ filteredSearchBarTokens?.reduce((accumulator, token) => {
+ return {
+ ...accumulator,
+ [token]: getParameterByName(token),
+ };
+ }, {}) || {};
+
+ if (filteredSearchBarSearchParam) {
+ filterParams[filteredSearchBarSearchParam] = getParameterByName(filteredSearchBarSearchParam);
+ }
+
+ return setUrlParams({ ...filterParams, sort: sortParam }, window.location.href, true);
+};
+
+// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js`
+export const canOverride = () => false;
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
index 25c357b6073..c803774f4a7 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
@@ -54,7 +54,6 @@ import { s__ } from '~/locale';
file.promptDiscardConfirmation = false;
file.resolveMode = DEFAULT_RESOLVE_MODE;
file.filePath = this.getFilePath(file);
- file.iconClass = `fa-${file.blob_icon}`;
file.blobPath = file.blob_path;
if (file.type === CONFLICT_TYPES.TEXT) {
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index a5a930572e1..229f6f3e339 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
import { deprecatedCreateFlash as createFlash } from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar';
import './merge_conflict_store';
@@ -24,6 +25,7 @@ export default function initMergeConflicts() {
gl.MergeConflictsResolverApp = new Vue({
el: '#conflicts',
components: {
+ FileIcon,
'diff-file-editor': gl.mergeConflicts.diffFileEditor,
'inline-conflict-lines': gl.mergeConflicts.inlineConflictLines,
'parallel-conflict-lines': gl.mergeConflicts.parallelConflictLines,
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index fe4e2cee69f..344f8dee5ea 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -102,14 +102,6 @@ MergeRequest.prototype.initMRBtnListeners = function() {
return $('.btn-close, .btn-reopen').on('click', function(e) {
const $this = $(this);
const shouldSubmit = $this.hasClass('btn-comment');
- if ($this.hasClass('js-btn-issue-action')) {
- const url = $this.data('endpoint');
- return axios
- .put(url)
- .then(() => window.location.reload())
- .catch(() => createFlash(__('Something went wrong.')));
- }
-
if (shouldSubmit && $this.data('submitted')) {
return;
}
@@ -171,10 +163,6 @@ MergeRequest.decreaseCounter = function(by = 1) {
MergeRequest.hideCloseButton = function() {
const el = document.querySelector('.merge-request .js-issuable-actions');
- const closeDropdownItem = el.querySelector('li.close-item');
- if (closeDropdownItem) {
- closeDropdownItem.classList.add('hidden');
- }
// Dropdown for mobile screen
el.querySelector('li.js-close-item').classList.add('hidden');
};
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index bdcdabe8f78..6e9661ea1a8 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -369,6 +369,9 @@ export default class MergeRequestTabs {
projectId: pipelineTableViewEl.dataset.projectId,
mergeRequestId: mrWidgetData ? mrWidgetData.iid : null,
},
+ provide: {
+ targetProjectFullPath: mrWidgetData?.target_project_full_path || '',
+ },
}).$mount();
// $mount(el) replaces the el with the new rendered component. We need it in order to mount
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 52f6786ca28..baa5e41989b 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -53,8 +53,7 @@ export default class MilestoneSelect {
const $block = $selectBox.closest('.block');
const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
const $value = $block.find('.value');
- // eslint-disable-next-line no-jquery/no-fade
- const $loading = $block.find('.block-loading').fadeOut();
+ const $loading = $block.find('.block-loading').addClass('gl-display-none');
selectedMilestoneDefault = showAny ? '' : null;
selectedMilestoneDefault =
showNo && defaultNo ? __('No milestone') : selectedMilestoneDefault;
@@ -255,34 +254,29 @@ export default class MilestoneSelect {
}
$dropdown.trigger('loading.gl.dropdown');
- // eslint-disable-next-line no-jquery/no-fade
- $loading.removeClass('hidden').fadeIn();
+ $loading.removeClass('gl-display-none');
boardsStore.detail.issue
.update($dropdown.attr('data-issue-update'))
.then(() => {
$dropdown.trigger('loaded.gl.dropdown');
- // eslint-disable-next-line no-jquery/no-fade
- $loading.fadeOut();
+ $loading.addClass('gl-display-none');
})
.catch(() => {
- // eslint-disable-next-line no-jquery/no-fade
- $loading.fadeOut();
+ $loading.addClass('gl-display-none');
});
} else {
selected = $selectBox.find('input[type="hidden"]').val();
data = {};
data[abilityName] = {};
data[abilityName].milestone_id = selected != null ? selected : null;
- // eslint-disable-next-line no-jquery/no-fade
- $loading.removeClass('hidden').fadeIn();
+ $loading.removeClass('gl-display-none');
$dropdown.trigger('loading.gl.dropdown');
return axios
.put(issueUpdateURL, data)
.then(({ data }) => {
$dropdown.trigger('loaded.gl.dropdown');
- // eslint-disable-next-line no-jquery/no-fade
- $loading.fadeOut();
+ $loading.addClass('gl-display-none');
$selectBox.hide();
$value.css('display', '');
if (data.milestone != null) {
@@ -313,8 +307,7 @@ export default class MilestoneSelect {
.text(__('None'));
})
.catch(() => {
- // eslint-disable-next-line no-jquery/no-fade
- $loading.fadeOut();
+ $loading.addClass('gl-display-none');
});
}
},
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index 818ca8aa847..18ea27e9a34 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -39,6 +39,7 @@ export default class MirrorRepos {
initMirrorSSH() {
if (this.$password) {
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$password.off('input.updateUrl');
}
this.$password = undefined;
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index eecfaa76168..c6486350f3b 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -185,10 +185,15 @@ export default class SSHMirror {
}
destroy() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$repositoryUrl.off('keyup');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$form.find('.js-known-hosts').off('keyup');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$dropdownAuthType.off('change');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$btnDetectHostKeys.off('click');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$btnSSHHostsShowAdvanced.off('click');
}
}
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
index 6f29b34141d..71691429ece 100644
--- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue
+++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
@@ -31,7 +31,7 @@ const SUBMIT_ACTION_TEXT = {
const SUBMIT_BUTTON_CLASS = {
create: 'btn-success',
update: 'btn-success',
- delete: 'btn-remove',
+ delete: 'btn-danger',
};
export default {
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index bda2adeb62a..170c5ff7695 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -367,6 +367,7 @@ export default {
},
);
+ // eslint-disable-next-line @gitlab/no-global-event-off
eChart.off('datazoom');
eChart.on('datazoom', this.throttledDatazoom);
},
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 597600bba07..ad7127d97de 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -394,10 +394,10 @@ export default {
data-qa-selector="prometheus_graph_widgets"
>
<div data-testid="dropdown-wrapper" class="d-flex align-items-center">
- <!--
+ <!--
This component should be replaced with a variant developed
as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936
- The variant will create a dropdown with an icon, no text and no caret
+ The variant will create a dropdown with an icon, no text and no caret
-->
<gl-dropdown
v-gl-tooltip
diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js
index 9245ffdb3b9..4ae5cf04ff9 100644
--- a/app/assets/javascripts/monitoring/stores/variable_mapping.js
+++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js
@@ -271,5 +271,3 @@ export const optionsFromSeriesData = ({ label, data = [] }) => {
return [...optionsSet].map(parseSimpleCustomValues);
};
-
-export default {};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 92bbce498d5..a4c5a881fae 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -404,5 +404,3 @@ export const barChartsDataParser = (data = []) =>
}),
{},
);
-
-export default {};
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index a3d7ddd5bad..dc5b2b66348 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -1,5 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
+import { GlSafeHtmlDirective } from '@gitlab/ui';
import { sanitize } from '~/lib/dompurify';
import Prompt from '../prompt.vue';
@@ -7,6 +7,9 @@ export default {
components: {
Prompt,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
count: {
type: Number,
@@ -23,9 +26,7 @@ export default {
},
computed: {
sanitizedOutput() {
- return sanitize(this.rawCode, {
- ALLOWED_ATTR: ['src'],
- });
+ return sanitize(this.rawCode);
},
showOutput() {
return this.index === 0;
@@ -37,6 +38,6 @@ export default {
<template>
<div class="output">
<prompt type="Out" :count="count" :show-output="showOutput" />
- <div class="gl-overflow-auto" v-html="sanitizedOutput"></div>
+ <div v-safe-html="sanitizedOutput" class="gl-overflow-auto"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index f2d3796cccf..113d8cfc435 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -31,6 +31,8 @@ export default {
return 'text/plain';
} else if (output.data['image/png']) {
return 'image/png';
+ } else if (output.data['image/jpeg']) {
+ return 'image/jpeg';
} else if (output.data['text/html']) {
return 'text/html';
} else if (output.data['image/svg+xml']) {
@@ -53,6 +55,8 @@ export default {
return CodeOutput;
} else if (output.data['image/png']) {
return ImageOutput;
+ } else if (output.data['image/jpeg']) {
+ return ImageOutput;
} else if (output.data['text/html']) {
return HtmlOutput;
} else if (output.data['image/svg+xml']) {
diff --git a/app/assets/javascripts/notebook/lib/highlight.js b/app/assets/javascripts/notebook/lib/highlight.js
index 74ade6d2edf..313aeecbd51 100644
--- a/app/assets/javascripts/notebook/lib/highlight.js
+++ b/app/assets/javascripts/notebook/lib/highlight.js
@@ -1,22 +1,5 @@
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
-import 'prismjs/plugins/custom-class/prism-custom-class';
-
-Prism.plugins.customClass.map({
- comment: 'c',
- error: 'err',
- operator: 'o',
- constant: 'kc',
- namespace: 'kn',
- keyword: 'k',
- string: 's',
- number: 'm',
- 'attr-name': 'na',
- builtin: 'nb',
- entity: 'ni',
- function: 'nf',
- tag: 'nt',
- variable: 'nv',
-});
+import 'prismjs/themes/prism.css';
export default Prism;
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 37bb79defd1..9a887021e5d 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -187,6 +187,7 @@ export default class Notes {
this.$wrapperEl.off('click', '.js-discussion-reply-button');
this.$wrapperEl.off('click', '.js-add-diff-note-button');
this.$wrapperEl.off('click', '.js-add-image-diff-note-button');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.$wrapperEl.off('visibilitychange');
this.$wrapperEl.off('keyup input', '.js-note-text');
this.$wrapperEl.off('click', '.js-note-target-reopen');
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 9cc53a320b8..0363173f912 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -3,23 +3,23 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { isEmpty } from 'lodash';
import Autosize from 'autosize';
-import { GlAlert, GlIntersperse, GlLink, GlSprintf, GlButton, GlIcon } from '@gitlab/ui';
+import { GlButton, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
-import { deprecatedCreateFlash as Flash } from '../../flash';
-import Autosave from '../../autosave';
+import { deprecatedCreateFlash as Flash } from '~/flash';
+import Autosave from '~/autosave';
import {
capitalizeFirstCharacter,
convertToCamelCase,
splitCamelCase,
slugifyWithUnderscore,
-} from '../../lib/utils/text_utility';
+} from '~/lib/utils/text_utility';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import * as constants from '../constants';
import eventHub from '../event_hub';
-import NoteableWarning from '../../vue_shared/components/notes/noteable_warning.vue';
-import markdownField from '../../vue_shared/components/markdown/field.vue';
-import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
+import markdownField from '~/vue_shared/components/markdown/field.vue';
+import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import issuableStateMixin from '../mixins/issuable_state';
@@ -34,10 +34,6 @@ export default {
userAvatarLink,
GlButton,
TimelineEntryItem,
- GlAlert,
- GlIntersperse,
- GlLink,
- GlSprintf,
GlIcon,
},
mixins: [issuableStateMixin],
@@ -63,9 +59,8 @@ export default {
'getNoteableDataByProp',
'getNotesData',
'openState',
- 'getBlockedByIssues',
]),
- ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']),
+ ...mapState(['isToggleStateButtonLoading']),
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
@@ -143,7 +138,7 @@ export default {
? __('merge request')
: __('issue');
},
- isIssueType() {
+ isIssue() {
return this.noteableDisplayName === constants.ISSUE_NOTEABLE_TYPE;
},
trackingLabel() {
@@ -172,11 +167,9 @@ export default {
'stopPolling',
'restartPolling',
'removePlaceholderNotes',
- 'closeIssue',
- 'reopenIssue',
+ 'closeIssuable',
+ 'reopenIssuable',
'toggleIssueLocalState',
- 'toggleStateButtonLoading',
- 'toggleBlockedIssueWarning',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!isEmpty(note) && !isSubmitting) {
@@ -186,8 +179,6 @@ export default {
}
},
handleSave(withIssueAction) {
- this.isSubmitting = true;
-
if (this.note.length) {
const noteData = {
endpoint: this.endpoint,
@@ -210,9 +201,10 @@ export default {
this.resizeTextarea();
this.stopPolling();
+ this.isSubmitting = true;
+
this.saveNote(noteData)
.then(() => {
- this.enableButton();
this.restartPolling();
this.discard();
@@ -221,7 +213,6 @@ export default {
}
})
.catch(() => {
- this.enableButton();
this.discard(false);
const msg = __(
'Your comment could not be submitted! Please check your network connection and try again.',
@@ -229,64 +220,27 @@ export default {
Flash(msg, 'alert', this.$el);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
+ })
+ .finally(() => {
+ this.isSubmitting = false;
});
} else {
this.toggleIssueState();
}
},
- enableButton() {
- this.isSubmitting = false;
- },
toggleIssueState() {
- if (
- this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE &&
- this.isOpen &&
- this.getBlockedByIssues &&
- this.getBlockedByIssues.length > 0
- ) {
- this.toggleBlockedIssueWarning(true);
+ if (this.isIssue) {
+ // We want to invoke the close/reopen logic in the issue header
+ // since that is where the blocked-by issues modal logic is also defined
+ eventHub.$emit('toggle.issuable.state');
return;
}
- if (this.isOpen) {
- this.forceCloseIssue();
- } else {
- this.reopenIssue()
- .then(() => {
- this.enableButton();
- refreshUserMergeRequestCounts();
- })
- .catch(({ data }) => {
- this.enableButton();
- this.toggleStateButtonLoading(false);
- let errorMessage = sprintf(
- __('Something went wrong while reopening the %{issuable}. Please try again later'),
- { issuable: this.noteableDisplayName },
- );
- if (data) {
- errorMessage = Object.values(data).join('\n');
- }
+ const toggleState = this.isOpen ? this.closeIssuable : this.reopenIssuable;
- Flash(errorMessage);
- });
- }
- },
- forceCloseIssue() {
- this.closeIssue()
- .then(() => {
- this.enableButton();
- refreshUserMergeRequestCounts();
- })
- .catch(() => {
- this.enableButton();
- this.toggleStateButtonLoading(false);
- Flash(
- sprintf(
- __('Something went wrong while closing the %{issuable}. Please try again later'),
- { issuable: this.noteableDisplayName },
- ),
- );
- });
+ toggleState()
+ .then(refreshUserMergeRequestCounts)
+ .catch(() => Flash(constants.toggleStateErrorMessage[this.noteableType][this.openState]));
},
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
@@ -384,6 +338,7 @@ export default {
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
data-qa-selector="comment_field"
+ data-testid="comment-field"
data-supports-quick-actions="true"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@@ -392,36 +347,7 @@ export default {
@keydown.ctrl.enter="handleSave()"
></textarea>
</markdown-field>
- <gl-alert
- v-if="isToggleBlockedIssueWarning"
- class="gl-mt-5"
- :title="__('Are you sure you want to close this blocked issue?')"
- :primary-button-text="__('Yes, close issue')"
- :secondary-button-text="__('Cancel')"
- variant="warning"
- :dismissible="false"
- @primaryAction="toggleBlockedIssueWarning(false) && forceCloseIssue()"
- @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()"
- >
- <p>
- <gl-sprintf
- :message="
- __('This issue is currently blocked by the following issues: %{issues}.')
- "
- >
- <template #issues>
- <gl-intersperse>
- <gl-link
- v-for="blockingIssue in getBlockedByIssues"
- :key="blockingIssue.web_url"
- :href="blockingIssue.web_url"
- >#{{ blockingIssue.iid }}</gl-link
- >
- </gl-intersperse>
- </template>
- </gl-sprintf>
- </p>
- </gl-alert>
+
<div class="note-form-actions">
<div
class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
@@ -430,6 +356,7 @@ export default {
:disabled="isSubmitButtonDisabled"
class="js-comment-button js-comment-submit-button"
data-qa-selector="comment_button"
+ data-testid="comment-button"
type="submit"
category="primary"
variant="success"
@@ -488,15 +415,13 @@ export default {
</div>
<gl-button
- v-if="canToggleIssueState && !isToggleBlockedIssueWarning"
+ v-if="canToggleIssueState"
:loading="isToggleStateButtonLoading"
category="secondary"
:variant="buttonVariant"
- :class="[
- actionButtonClassNames,
- 'btn-comment btn-comment-and-close js-action-button',
- ]"
- :disabled="isToggleStateButtonLoading || isSubmitting"
+ :class="[actionButtonClassNames, 'btn-comment btn-comment-and-close']"
+ :disabled="isSubmitting"
+ data-testid="close-reopen-button"
@click="handleSave(true)"
>{{ issueActionButtonTitle }}</gl-button
>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index 91cf682943e..1580c94658a 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -7,7 +7,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { getDiffMode } from '~/diffs/store/utils';
import { diffViewerModes } from '~/ide/constants';
-import { isCollapsed } from '../../diffs/diff_file';
+import { isCollapsed } from '../../diffs/utils/diff_file';
const FIRST_CHAR_REGEX = /^(\+|-| )/;
@@ -131,14 +131,18 @@ export default {
:file-hash="discussion.diff_file.file_hash"
:project-path="projectPath"
>
- <image-diff-overlay
- slot="image-overlay"
- :discussions="discussion"
- :file-hash="discussion.diff_file.file_hash"
- :show-comment-icon="true"
- :should-toggle-discussion="false"
- badge-class="image-comment-badge"
- />
+ <template #image-overlay="{ renderedWidth, renderedHeight }">
+ <image-diff-overlay
+ v-if="renderedWidth"
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ :discussions="discussion"
+ :file-hash="discussion.diff_file.file_hash"
+ :show-comment-icon="true"
+ :should-toggle-discussion="false"
+ badge-class="image-comment-badge gl-text-gray-500"
+ />
+ </template>
</diff-viewer>
<slot></slot>
</div>
diff --git a/app/assets/javascripts/notes/components/multiline_comment_utils.js b/app/assets/javascripts/notes/components/multiline_comment_utils.js
index dbae10c8f6c..2451400e980 100644
--- a/app/assets/javascripts/notes/components/multiline_comment_utils.js
+++ b/app/assets/javascripts/notes/components/multiline_comment_utils.js
@@ -103,9 +103,15 @@ export function getCommentedLines(selectedCommentPosition, diffLines) {
};
}
+ const findLineCodeIndex = line => position => {
+ return [position.line_code, position.left?.line_code, position.right?.line_code].includes(
+ line.line_code,
+ );
+ };
+
const { start, end } = selectedCommentPosition;
- const startLine = diffLines.findIndex(l => l.line_code === start.line_code);
- const endLine = diffLines.findIndex(l => l.line_code === end.line_code);
+ const startLine = diffLines.findIndex(findLineCodeIndex(start));
+ const endLine = diffLines.findIndex(findLineCodeIndex(end));
return { startLine, endLine };
}
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 43f17c5d65c..84769bfc7c8 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -422,7 +422,7 @@ export default {
</button>
<button
v-if="discussion.resolvable"
- class="btn btn-nr btn-default gl-mr-3 js-comment-resolve-button"
+ class="btn btn-default gl-mr-3 js-comment-resolve-button"
@click.prevent="handleUpdate(true)"
>
{{ resolveButtonTitle }}
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index cacf209ed81..17a995018d3 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -85,7 +85,10 @@ export default {
};
},
authorStatus() {
- return this.author.status_tooltip_html;
+ if (this.author?.show_status) {
+ return this.author.status_tooltip_html;
+ }
+ return false;
},
authorIsBusy() {
const { status } = this.author;
@@ -142,7 +145,7 @@ export default {
type="button"
@click="handleToggle"
>
- <gl-icon ref="chevronIcon" :name="toggleChevronIconName" aria-hidden="true" />
+ <gl-icon ref="chevronIcon" :name="toggleChevronIconName" />
{{ __('Toggle thread') }}
</button>
</div>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 9be53fe60f2..5073922e4a4 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -23,6 +23,7 @@ import {
commentLineOptions,
formatLineRange,
} from './multiline_comment_utils';
+import { INLINE_DIFF_LINES_KEY } from '~/diffs/constants';
export default {
name: 'NoteableNote',
@@ -169,12 +170,8 @@ export default {
return this.line && this.startLineNumber !== this.endLineNumber;
},
commentLineOptions() {
- const sideA = this.line.type === 'new' ? 'right' : 'left';
- const sideB = sideA === 'left' ? 'right' : 'left';
- const lines = this.diffFile.highlighted_diff_lines.length
- ? this.diffFile.highlighted_diff_lines
- : this.diffFile.parallel_diff_lines.map(l => l[sideA] || l[sideB]);
- return commentLineOptions(lines, this.commentLineStart, this.line.line_code, sideA);
+ const lines = this.diffFile[INLINE_DIFF_LINES_KEY].length;
+ return commentLineOptions(lines, this.commentLineStart, this.line.line_code);
},
diffFile() {
if (this.commentLineStart.line_code) {
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 7acf2ad57c8..cc14ea42a89 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const DISCUSSION_NOTE = 'DiscussionNote';
export const DIFF_NOTE = 'DiffNote';
export const DISCUSSION = 'discussion';
@@ -36,3 +38,16 @@ export const DISCUSSION_FILTER_TYPES = {
COMMENTS: 'comments',
HISTORY: 'history',
};
+
+export const toggleStateErrorMessage = {
+ Epic: {
+ [CLOSED]: __('Something went wrong while reopening the epic. Please try again later.'),
+ [OPENED]: __('Something went wrong while closing the epic. Please try again later.'),
+ [REOPENED]: __('Something went wrong while closing the epic. Please try again later.'),
+ },
+ MergeRequest: {
+ [CLOSED]: __('Something went wrong while reopening the merge request. Please try again later.'),
+ [OPENED]: __('Something went wrong while closing the merge request. Please try again later.'),
+ [REOPENED]: __('Something went wrong while closing the merge request. Please try again later.'),
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 61298a15c5d..c6932bfacae 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,16 +1,17 @@
import { mapGetters, mapActions, mapState } from 'vuex';
-import { scrollToElementWithContext } from '~/lib/utils/common_utils';
+import { scrollToElementWithContext, scrollToElement } from '~/lib/utils/common_utils';
import eventHub from '../event_hub';
/**
* @param {string} selector
* @returns {boolean}
*/
-function scrollTo(selector) {
+function scrollTo(selector, { withoutContext = false } = {}) {
const el = document.querySelector(selector);
+ const scrollFunction = withoutContext ? scrollToElement : scrollToElementWithContext;
if (el) {
- scrollToElementWithContext(el);
+ scrollFunction(el);
return true;
}
@@ -35,7 +36,7 @@ function diffsJump({ expandDiscussion }, id) {
function discussionJump({ expandDiscussion }, id) {
const selector = `div.discussion[data-discussion-id="${id}"]`;
expandDiscussion({ discussionId: id });
- return scrollTo(selector);
+ return scrollTo(selector, { withoutContext: true });
}
/**
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 2c60b5ee84a..1fe5d6c2955 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -244,21 +244,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
});
};
-export const toggleBlockedIssueWarning = ({ commit }, value) => {
- commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value);
- // Hides Close issue button at the top of issue page
- const closeDropdown = document.querySelector('.js-issuable-close-dropdown');
- if (closeDropdown) {
- closeDropdown.classList.toggle('d-none');
- } else {
- const closeButton = document.querySelector(
- '.detail-page-header-actions .btn-close.btn-grouped',
- );
- closeButton.classList.toggle('d-md-block');
- }
-};
-
-export const closeIssue = ({ commit, dispatch, state }) => {
+export const closeIssuable = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return axios.put(state.notesData.closePath).then(({ data }) => {
commit(types.CLOSE_ISSUE);
@@ -267,7 +253,7 @@ export const closeIssue = ({ commit, dispatch, state }) => {
});
};
-export const reopenIssue = ({ commit, dispatch, state }) => {
+export const reopenIssuable = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return axios.put(state.notesData.reopenPath).then(({ data }) => {
commit(types.REOPEN_ISSUE);
@@ -435,6 +421,10 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
};
const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => {
+ if (state.isResolvingDiscussion) {
+ return null;
+ }
+
if (resp.notes?.length) {
dispatch('updateOrCreateNotes', resp.notes);
dispatch('startTaskList');
@@ -574,6 +564,9 @@ export const submitSuggestion = (
const dispatchResolveDiscussion = () =>
dispatch('resolveDiscussion', { discussionId }).catch(() => {});
+ commit(types.SET_RESOLVING_DISCUSSION, true);
+ dispatch('stopPolling');
+
return Api.applySuggestion(suggestionId)
.then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }))
.then(dispatchResolveDiscussion)
@@ -587,6 +580,10 @@ export const submitSuggestion = (
const flashMessage = errorMessage || defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
+ })
+ .finally(() => {
+ commit(types.SET_RESOLVING_DISCUSSION, false);
+ dispatch('restartPolling');
});
};
@@ -605,6 +602,8 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai
});
commit(types.SET_APPLYING_BATCH_STATE, true);
+ commit(types.SET_RESOLVING_DISCUSSION, true);
+ dispatch('stopPolling');
return Api.applySuggestionBatch(suggestionIds)
.then(() => Promise.all(applyAllSuggestions()))
@@ -621,7 +620,11 @@ export const submitSuggestionBatch = ({ commit, dispatch, state }, { flashContai
Flash(__(flashMessage), 'alert', flashContainer);
})
- .finally(() => commit(types.SET_APPLYING_BATCH_STATE, false));
+ .finally(() => {
+ commit(types.SET_APPLYING_BATCH_STATE, false);
+ commit(types.SET_RESOLVING_DISCUSSION, false);
+ dispatch('restartPolling');
+ });
};
export const addSuggestionInfoToBatch = ({ commit }, { suggestionId, noteId, discussionId }) =>
diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js
index d94fc626a3f..f34247d4eb0 100644
--- a/app/assets/javascripts/notes/stores/collapse_utils.js
+++ b/app/assets/javascripts/notes/stores/collapse_utils.js
@@ -70,6 +70,3 @@ export const collapseSystemNotes = notes => {
return acc;
}, []);
};
-
-// for babel-rewire
-export default {};
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index a8738fa7c5f..4421a84a6b1 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -26,7 +26,6 @@ export default () => ({
// View layer
isToggleStateButtonLoading: false,
- isToggleBlockedIssueWarning: false,
isNotesFetched: false,
isLoading: true,
isLoadingDescriptionVersion: false,
@@ -42,6 +41,7 @@ export default () => ({
current_user: {},
preview_note_path: 'path/to/preview',
},
+ isResolvingDiscussion: false,
commentsDisabled: false,
resolvableDiscussionsCount: 0,
unresolvedDiscussionsCount: 0,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 7496dd630f6..5c4f62f4575 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -38,12 +38,12 @@ export const SET_TIMELINE_VIEW = 'SET_TIMELINE_VIEW';
export const SET_SELECTED_COMMENT_POSITION = 'SET_SELECTED_COMMENT_POSITION';
export const SET_SELECTED_COMMENT_POSITION_HOVER = 'SET_SELECTED_COMMENT_POSITION_HOVER';
export const SET_FETCHING_DISCUSSIONS = 'SET_FETCHING_DISCUSSIONS';
+export const SET_RESOLVING_DISCUSSION = 'SET_RESOLVING_DISCUSSION';
// Issue
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
export const REOPEN_ISSUE = 'REOPEN_ISSUE';
export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
-export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING';
export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL';
export const SET_ISSUABLE_LOCK = 'SET_ISSUABLE_LOCK';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 7cc619ec1c5..53387b2eaff 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -213,6 +213,10 @@ export default {
}
},
+ [types.SET_RESOLVING_DISCUSSION](state, isResolving) {
+ state.isResolvingDiscussion = isResolving;
+ },
+
[types.UPDATE_NOTE](state, note) {
const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id);
@@ -301,10 +305,6 @@ export default {
Object.assign(state, { isToggleStateButtonLoading: value });
},
- [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) {
- Object.assign(state, { isToggleBlockedIssueWarning: value });
- },
-
[types.SET_NOTES_FETCHED_STATE](state, value) {
Object.assign(state, { isNotesFetched: value });
},
diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages/details/components/app.vue
index af3220840a6..c9f1c8b903c 100644
--- a/app/assets/javascripts/packages/details/components/app.vue
+++ b/app/assets/javascripts/packages/details/components/app.vue
@@ -5,29 +5,26 @@ import {
GlModal,
GlModalDirective,
GlTooltipDirective,
- GlLink,
GlEmptyState,
GlTab,
GlTabs,
- GlTable,
GlSprintf,
} from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import Tracking from '~/tracking';
+import { s__ } from '~/locale';
+import { objectToQueryString } from '~/lib/utils/common_utils';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
import PackageHistory from './package_history.vue';
import PackageTitle from './package_title.vue';
import PackagesListLoader from '../../shared/components/packages_list_loader.vue';
import PackageListRow from '../../shared/components/package_list_row.vue';
+import { packageTypeToTrackCategory } from '../../shared/utils';
+import { PackageType, TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '../../shared/constants';
import DependencyRow from './dependency_row.vue';
import AdditionalMetadata from './additional_metadata.vue';
import InstallationCommands from './installation_commands.vue';
-import { numberToHumanSize } from '~/lib/utils/number_utils';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import FileIcon from '~/vue_shared/components/file_icon.vue';
-import { __, s__ } from '~/locale';
-import { PackageType, TrackingActions, SHOW_DELETE_SUCCESS_ALERT } from '../../shared/constants';
-import { packageTypeToTrackCategory } from '../../shared/utils';
-import { objectToQueryString } from '~/lib/utils/common_utils';
+import PackageFiles from './package_files.vue';
export default {
name: 'PackagesApp',
@@ -35,12 +32,9 @@ export default {
GlBadge,
GlButton,
GlEmptyState,
- GlLink,
GlModal,
GlTab,
GlTabs,
- GlTable,
- FileIcon,
GlSprintf,
PackageTitle,
PackagesListLoader,
@@ -49,12 +43,13 @@ export default {
PackageHistory,
AdditionalMetadata,
InstallationCommands,
+ PackageFiles,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
- mixins: [timeagoMixin, Tracking.mixin()],
+ mixins: [Tracking.mixin()],
trackingActions: { ...TrackingActions },
computed: {
...mapState([
@@ -72,14 +67,6 @@ export default {
isValidPackage() {
return Boolean(this.packageEntity.name);
},
- filesTableRows() {
- return this.packageFiles.map(x => ({
- name: x.file_name,
- downloadPath: x.download_path,
- size: this.formatSize(x.size),
- created: x.created_at,
- }));
- },
tracking() {
return {
category: packageTypeToTrackCategory(this.packageEntity.package_type),
@@ -128,22 +115,6 @@ export default {
`PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`,
),
},
- filesTableHeaderFields: [
- {
- key: 'name',
- label: __('Name'),
- tdClass: 'd-flex align-items-center',
- },
- {
- key: 'size',
- label: __('Size'),
- },
- {
- key: 'created',
- label: __('Created'),
- class: 'text-right',
- },
- ],
};
</script>
@@ -185,35 +156,11 @@ export default {
<additional-metadata :package-entity="packageEntity" />
</div>
- <template v-if="showFiles">
- <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
- <gl-table
- :fields="$options.filesTableHeaderFields"
- :items="filesTableRows"
- tbody-tr-class="js-file-row"
- >
- <template #cell(name)="{ item }">
- <gl-link
- :href="item.downloadPath"
- class="js-file-download gl-relative"
- @click="track($options.trackingActions.PULL_PACKAGE)"
- >
- <file-icon
- :file-name="item.name"
- css-classes="gl-relative file-icon"
- class="gl-mr-1 gl-relative"
- />
- <span class="gl-relative">{{ item.name }}</span>
- </gl-link>
- </template>
-
- <template #cell(created)="{ item }">
- <span v-gl-tooltip :title="tooltipTitle(item.created)">{{
- timeFormatted(item.created)
- }}</span>
- </template>
- </gl-table>
- </template>
+ <package-files
+ v-if="showFiles"
+ :package-files="packageFiles"
+ @download-file="track($options.trackingActions.PULL_PACKAGE)"
+ />
</gl-tab>
<gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab">
diff --git a/app/assets/javascripts/packages/details/components/package_files.vue b/app/assets/javascripts/packages/details/components/package_files.vue
new file mode 100644
index 00000000000..ab46dd0114d
--- /dev/null
+++ b/app/assets/javascripts/packages/details/components/package_files.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlLink, GlTable } from '@gitlab/ui';
+import { last } from 'lodash';
+import { __ } from '~/locale';
+import Tracking from '~/tracking';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+
+export default {
+ name: 'PackageFiles',
+ components: {
+ GlLink,
+ GlTable,
+ FileIcon,
+ TimeAgoTooltip,
+ },
+ mixins: [Tracking.mixin()],
+ props: {
+ packageFiles: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ filesTableRows() {
+ return this.packageFiles.map(pf => ({
+ ...pf,
+ size: this.formatSize(pf.size),
+ pipeline: last(pf.pipelines),
+ }));
+ },
+ showCommitColumn() {
+ return this.filesTableRows.some(row => Boolean(row.pipeline?.id));
+ },
+ filesTableHeaderFields() {
+ return [
+ {
+ key: 'name',
+ label: __('Name'),
+ tdClass: 'gl-display-flex gl-align-items-center',
+ },
+ {
+ key: 'commit',
+ label: __('Commit'),
+ hide: !this.showCommitColumn,
+ },
+ {
+ key: 'size',
+ label: __('Size'),
+ },
+ {
+ key: 'created',
+ label: __('Created'),
+ class: 'gl-text-right',
+ },
+ ].filter(c => !c.hide);
+ },
+ },
+ methods: {
+ formatSize(size) {
+ return numberToHumanSize(size);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3>
+ <gl-table
+ :fields="filesTableHeaderFields"
+ :items="filesTableRows"
+ :tbody-tr-attr="{ 'data-testid': 'file-row' }"
+ >
+ <template #cell(name)="{ item }">
+ <gl-link
+ :href="item.download_path"
+ class="gl-relative gl-text-gray-500"
+ data-testid="download-link"
+ @click="$emit('download-file')"
+ >
+ <file-icon
+ :file-name="item.file_name"
+ css-classes="gl-relative file-icon"
+ class="gl-mr-1 gl-relative"
+ />
+ <span class="gl-relative">{{ item.file_name }}</span>
+ </gl-link>
+ </template>
+
+ <template #cell(commit)="{item}">
+ <gl-link
+ :href="item.pipeline.project.commit_url"
+ class="gl-text-gray-500"
+ data-testid="commit-link"
+ >{{ item.pipeline.git_commit_message }}</gl-link
+ >
+ </template>
+
+ <template #cell(created)="{ item }">
+ <time-ago-tooltip :time="item.created_at" />
+ </template>
+ </gl-table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages/details/components/package_history.vue b/app/assets/javascripts/packages/details/components/package_history.vue
index 413ab1d15cb..62550602428 100644
--- a/app/assets/javascripts/packages/details/components/package_history.vue
+++ b/app/assets/javascripts/packages/details/components/package_history.vue
@@ -1,17 +1,26 @@
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { first } from 'lodash';
+import { s__, n__ } from '~/locale';
+import { truncateSha } from '~/lib/utils/text_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import HistoryItem from '~/vue_shared/components/registry/history_item.vue';
+import { HISTORY_PIPELINES_LIMIT } from '~/packages/details/constants';
export default {
name: 'PackageHistory',
i18n: {
- createdOn: s__('PackageRegistry|%{name} version %{version} was created %{datetime}'),
- updatedAtText: s__('PackageRegistry|%{name} version %{version} was updated %{datetime}'),
- commitText: s__('PackageRegistry|Commit %{link} on branch %{branch}'),
- pipelineText: s__('PackageRegistry|Pipeline %{link} triggered %{datetime} by %{author}'),
+ createdOn: s__('PackageRegistry|%{name} version %{version} was first created %{datetime}'),
+ createdByCommitText: s__('PackageRegistry|Created by commit %{link} on branch %{branch}'),
+ createdByPipelineText: s__(
+ 'PackageRegistry|Built by pipeline %{link} triggered %{datetime} by %{author}',
+ ),
publishText: s__('PackageRegistry|Published to the %{project} Package Registry %{datetime}'),
+ combinedUpdateText: s__(
+ 'PackageRegistry|Package updated by commit %{link} on branch %{branch}, built by pipeline %{pipeline}, and published to the registry %{datetime}',
+ ),
+ archivedPipelineMessageSingular: s__('PackageRegistry|Package has %{number} archived update'),
+ archivedPipelineMessagePlural: s__('PackageRegistry|Package has %{number} archived updates'),
},
components: {
GlLink,
@@ -35,8 +44,32 @@ export default {
};
},
computed: {
- packagePipeline() {
- return this.packageEntity.pipeline?.id ? this.packageEntity.pipeline : null;
+ pipelines() {
+ return this.packageEntity.pipelines || [];
+ },
+ firstPipeline() {
+ return first(this.pipelines);
+ },
+ lastPipelines() {
+ return this.pipelines.slice(1).slice(-HISTORY_PIPELINES_LIMIT);
+ },
+ showPipelinesInfo() {
+ return Boolean(this.firstPipeline?.id);
+ },
+ archiviedLines() {
+ return Math.max(this.pipelines.length - HISTORY_PIPELINES_LIMIT - 1, 0);
+ },
+ archivedPipelineMessage() {
+ return n__(
+ this.$options.i18n.archivedPipelineMessageSingular,
+ this.$options.i18n.archivedPipelineMessagePlural,
+ this.archiviedLines,
+ );
+ },
+ },
+ methods: {
+ truncate(value) {
+ return truncateSha(value);
},
},
};
@@ -59,46 +92,35 @@ export default {
</template>
</gl-sprintf>
</history-item>
- <history-item icon="pencil" data-testid="updated-at">
- <gl-sprintf :message="$options.i18n.updatedAtText">
- <template #name>
- <strong>{{ packageEntity.name }}</strong>
- </template>
- <template #version>
- <strong>{{ packageEntity.version }}</strong>
- </template>
- <template #datetime>
- <time-ago-tooltip :time="packageEntity.updated_at" />
- </template>
- </gl-sprintf>
- </history-item>
- <template v-if="packagePipeline">
- <history-item icon="commit" data-testid="commit">
- <gl-sprintf :message="$options.i18n.commitText">
+
+ <template v-if="showPipelinesInfo">
+ <!-- FIRST PIPELINE BLOCK -->
+ <history-item icon="commit" data-testid="first-pipeline-commit">
+ <gl-sprintf :message="$options.i18n.createdByCommitText">
<template #link>
- <gl-link :href="packagePipeline.project.commit_url">{{
- packagePipeline.sha
- }}</gl-link>
+ <gl-link :href="firstPipeline.project.commit_url"
+ >#{{ truncate(firstPipeline.sha) }}</gl-link
+ >
</template>
<template #branch>
- <strong>{{ packagePipeline.ref }}</strong>
+ <strong>{{ firstPipeline.ref }}</strong>
</template>
</gl-sprintf>
</history-item>
- <history-item icon="pipeline" data-testid="pipeline">
- <gl-sprintf :message="$options.i18n.pipelineText">
+ <history-item icon="pipeline" data-testid="first-pipeline-pipeline">
+ <gl-sprintf :message="$options.i18n.createdByPipelineText">
<template #link>
- <gl-link :href="packagePipeline.project.pipeline_url"
- >#{{ packagePipeline.id }}</gl-link
- >
+ <gl-link :href="firstPipeline.project.pipeline_url">#{{ firstPipeline.id }}</gl-link>
</template>
<template #datetime>
- <time-ago-tooltip :time="packagePipeline.created_at" />
+ <time-ago-tooltip :time="firstPipeline.created_at" />
</template>
- <template #author>{{ packagePipeline.user.name }}</template>
+ <template #author>{{ firstPipeline.user.name }}</template>
</gl-sprintf>
</history-item>
</template>
+
+ <!-- PUBLISHED LINE -->
<history-item icon="package" data-testid="published">
<gl-sprintf :message="$options.i18n.publishText">
<template #project>
@@ -109,6 +131,37 @@ export default {
</template>
</gl-sprintf>
</history-item>
+
+ <history-item v-if="archiviedLines" icon="history" data-testid="archived">
+ <gl-sprintf :message="archivedPipelineMessage">
+ <template #number>
+ <strong>{{ archiviedLines }}</strong>
+ </template>
+ </gl-sprintf>
+ </history-item>
+
+ <!-- PIPELINES LIST ENTRIES -->
+ <history-item
+ v-for="pipeline in lastPipelines"
+ :key="pipeline.id"
+ icon="pencil"
+ data-testid="pipeline-entry"
+ >
+ <gl-sprintf :message="$options.i18n.combinedUpdateText">
+ <template #link>
+ <gl-link :href="pipeline.project.commit_url">#{{ truncate(pipeline.sha) }}</gl-link>
+ </template>
+ <template #branch>
+ <strong>{{ pipeline.ref }}</strong>
+ </template>
+ <template #pipeline>
+ <gl-link :href="pipeline.project.pipeline_url">#{{ pipeline.id }}</gl-link>
+ </template>
+ <template #datetime>
+ <time-ago-tooltip :time="pipeline.created_at" />
+ </template>
+ </gl-sprintf>
+ </history-item>
</ul>
</div>
</template>
diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js
index c6e1b388132..986b0667356 100644
--- a/app/assets/javascripts/packages/details/constants.js
+++ b/app/assets/javascripts/packages/details/constants.js
@@ -45,3 +45,5 @@ export const NpmManager = {
export const FETCH_PACKAGE_VERSIONS_ERROR = s__(
'PackageRegistry|Unable to fetch package version information.',
);
+
+export const HISTORY_PIPELINES_LIMIT = 5;
diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js
index 6a0e92bff2d..e14696e0d1c 100644
--- a/app/assets/javascripts/packages/list/constants.js
+++ b/app/assets/javascripts/packages/list/constants.js
@@ -68,6 +68,10 @@ export const PACKAGE_REGISTRY_TABS = [
title: s__('PackageRegistry|Conan'),
type: PackageType.CONAN,
},
+ {
+ title: s__('PackageRegistry|Generic'),
+ type: PackageType.GENERIC,
+ },
{
title: s__('PackageRegistry|Maven'),
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index c481abd8658..c0f7f150337 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',
+ GENERIC: 'generic',
};
export const TrackingActions = {
diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js
index b0807558266..d7a883e4397 100644
--- a/app/assets/javascripts/packages/shared/utils.js
+++ b/app/assets/javascripts/packages/shared/utils.js
@@ -21,7 +21,8 @@ export const getPackageTypeLabel = packageType => {
return s__('PackageType|PyPI');
case PackageType.COMPOSER:
return s__('PackageType|Composer');
-
+ case PackageType.GENERIC:
+ return s__('PackageType|Generic');
default:
return null;
}
diff --git a/app/assets/javascripts/pager.js b/app/assets/javascripts/pager.js
index 2aa37842707..f9a91ec322b 100644
--- a/app/assets/javascripts/pager.js
+++ b/app/assets/javascripts/pager.js
@@ -72,6 +72,7 @@ export default {
},
initLoadMore() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$(document).off('scroll');
$(document).endlessScroll({
bottomPixels: ENDLESS_SCROLL_BOTTOM_PX,
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index 143d15f92cd..cce30e6b12a 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -1,7 +1,6 @@
import initSettingsPanels from '~/settings_panels';
import projectSelect from '~/project_select';
import selfMonitor from '~/self_monitor';
-import maintenanceModeSettings from '~/maintenance_mode_settings';
import initVariableList from '~/ci_variable_list';
document.addEventListener('DOMContentLoaded', () => {
@@ -9,7 +8,6 @@ document.addEventListener('DOMContentLoaded', () => {
initVariableList('js-instance-variables');
}
selfMonitor();
- maintenanceModeSettings();
// Initialize expandable settings panels
initSettingsPanels();
projectSelect();
diff --git a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
index a08d32028c3..24c9fa4cb3f 100644
--- a/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
+++ b/app/assets/javascripts/pages/admin/users/components/user_modal_manager.vue
@@ -1,14 +1,13 @@
<script>
+import DeleteUserModal from './delete_user_modal.vue';
+
export default {
+ components: { DeleteUserModal },
props: {
modalConfiguration: {
required: true,
type: Object,
},
- actionModals: {
- required: true,
- type: Object,
- },
csrfToken: {
required: true,
type: String,
@@ -21,10 +20,7 @@ export default {
},
computed: {
activeModal() {
- if (!this.currentModalData) return null;
- const { glModalAction: action } = this.currentModalData;
-
- return this.actionModals[action];
+ return Boolean(this.currentModalData);
},
modalProps() {
@@ -56,9 +52,7 @@ export default {
show(modalData) {
const { glModalAction: requestedAction } = modalData;
- if (!this.actionModals[requestedAction]) {
- throw new Error(`Requested non-existing modal action ${requestedAction}`);
- }
+
if (!this.modalConfiguration[requestedAction]) {
throw new Error(`Modal action ${requestedAction} has no configuration in HTML`);
}
@@ -73,5 +67,5 @@ export default {
};
</script>
<template>
- <div :is="activeModal" v-if="activeModal" ref="modal" v-bind="modalProps" />
+ <delete-user-modal v-if="activeModal" ref="modal" v-bind="modalProps" />
</template>
diff --git a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue b/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue
deleted file mode 100644
index 4ca6ce6f1c3..00000000000
--- a/app/assets/javascripts/pages/admin/users/components/user_operation_confirmation_modal.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-<script>
-/* eslint-disable vue/no-v-html */
-import { GlModal } from '@gitlab/ui';
-import { sprintf } from '~/locale';
-
-export default {
- components: {
- GlModal,
- },
- props: {
- title: {
- type: String,
- required: true,
- },
- content: {
- type: String,
- required: true,
- },
- action: {
- type: String,
- required: true,
- },
- url: {
- type: String,
- required: true,
- },
- username: {
- type: String,
- required: true,
- },
- csrfToken: {
- type: String,
- required: true,
- },
- method: {
- type: String,
- required: false,
- default: 'put',
- },
- },
- computed: {
- modalTitle() {
- return sprintf(this.title, { username: this.username });
- },
- },
- methods: {
- show() {
- this.$refs.modal.show();
- },
- submit() {
- this.$refs.form.submit();
- },
- },
-};
-</script>
-<template>
- <gl-modal
- ref="modal"
- modal-id="user-operation-modal"
- :title="modalTitle"
- ok-variant="warning"
- :ok-title="action"
- @ok="submit"
- >
- <form ref="form" :action="url" method="post">
- <span v-html="content"></span>
- <input ref="method" type="hidden" name="_method" :value="method" />
- <input :value="csrfToken" type="hidden" name="authenticity_token" />
- </form>
- </gl-modal>
-</template>
diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js
index 5f3cdc0bfc6..07462b4592f 100644
--- a/app/assets/javascripts/pages/admin/users/index.js
+++ b/app/assets/javascripts/pages/admin/users/index.js
@@ -2,18 +2,12 @@ import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import ModalManager from './components/user_modal_manager.vue';
-import DeleteUserModal from './components/delete_user_modal.vue';
-import UserOperationConfirmationModal from './components/user_operation_confirmation_modal.vue';
import csrf from '~/lib/utils/csrf';
import initConfirmModal from '~/confirm_modal';
+import initAdminUsersApp from '~/admin/users';
-const MODAL_TEXTS_CONTAINER_SELECTOR = '#modal-texts';
-const MODAL_MANAGER_SELECTOR = '#user-modal';
-const ACTION_MODALS = {
- deactivate: UserOperationConfirmationModal,
- delete: DeleteUserModal,
- 'delete-with-contributions': DeleteUserModal,
-};
+const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts';
+const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal';
function loadModalsConfigurationFromHtml(modalsElement) {
const modalsConfiguration = {};
@@ -56,7 +50,6 @@ document.addEventListener('DOMContentLoaded', () => {
ref: 'manager',
props: {
modalConfiguration,
- actionModals: ACTION_MODALS,
csrfToken: csrf.token,
},
});
@@ -64,4 +57,5 @@ document.addEventListener('DOMContentLoaded', () => {
});
initConfirmModal();
+ initAdminUsersApp();
});
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 009a3eee526..d3900b84fa7 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -6,6 +6,7 @@ import groupsSelect from '~/groups_select';
import RemoveMemberModal from '~/vue_shared/components/remove_member_modal.vue';
import { initGroupMembersApp } from '~/groups/members';
import { memberRequestFormatter, groupLinkRequestFormatter } from '~/groups/members/utils';
+import { s__ } from '~/locale';
function mountRemoveMemberModal() {
const el = document.querySelector('.js-remove-member-modal');
@@ -22,30 +23,43 @@ function mountRemoveMemberModal() {
}
const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions'];
-initGroupMembersApp(
- document.querySelector('.js-group-members-list'),
- SHARED_FIELDS.concat(['source', 'granted']),
- { tr: { 'data-qa-selector': 'member_row' } },
- memberRequestFormatter,
-);
-initGroupMembersApp(
- document.querySelector('.js-group-linked-list'),
- SHARED_FIELDS.concat('granted'),
- { table: { 'data-qa-selector': 'groups_list' }, tr: { 'data-qa-selector': 'group_row' } },
- groupLinkRequestFormatter,
-);
-initGroupMembersApp(
- document.querySelector('.js-group-invited-members-list'),
- SHARED_FIELDS.concat('invited'),
- {},
- memberRequestFormatter,
-);
-initGroupMembersApp(
- document.querySelector('.js-group-access-requests-list'),
- SHARED_FIELDS.concat('requested'),
- {},
- memberRequestFormatter,
-);
+
+initGroupMembersApp(document.querySelector('.js-group-members-list'), {
+ tableFields: SHARED_FIELDS.concat(['source', 'granted']),
+ tableAttrs: { tr: { 'data-qa-selector': 'member_row' } },
+ tableSortableFields: ['account', 'granted', 'maxRole', 'lastSignIn'],
+ requestFormatter: memberRequestFormatter,
+ filteredSearchBar: {
+ show: true,
+ tokens: ['two_factor', 'with_inherited_permissions'],
+ searchParam: 'search',
+ placeholder: s__('Members|Filter members'),
+ recentSearchesStorageKey: 'group_members',
+ },
+});
+initGroupMembersApp(document.querySelector('.js-group-linked-list'), {
+ tableFields: SHARED_FIELDS.concat('granted'),
+ tableAttrs: {
+ table: { 'data-qa-selector': 'groups_list' },
+ tr: { 'data-qa-selector': 'group_row' },
+ },
+ requestFormatter: groupLinkRequestFormatter,
+});
+initGroupMembersApp(document.querySelector('.js-group-invited-members-list'), {
+ tableFields: SHARED_FIELDS.concat('invited'),
+ requestFormatter: memberRequestFormatter,
+ filteredSearchBar: {
+ show: true,
+ tokens: [],
+ searchParam: 'search_invited',
+ placeholder: s__('Members|Search invited'),
+ recentSearchesStorageKey: 'group_invited_members',
+ },
+});
+initGroupMembersApp(document.querySelector('.js-group-access-requests-list'), {
+ tableFields: SHARED_FIELDS.concat('requested'),
+ requestFormatter: memberRequestFormatter,
+});
groupsSelect();
memberExpirationDate();
diff --git a/app/assets/javascripts/pages/import/bitbucket/status/index.js b/app/assets/javascripts/pages/import/bitbucket/status/index.js
index 2a5432ce09d..f450a2aac00 100644
--- a/app/assets/javascripts/pages/import/bitbucket/status/index.js
+++ b/app/assets/javascripts/pages/import/bitbucket/status/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
-import { initStoreFromElement, initPropsFromElement } from '~/import_projects';
-import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue';
+import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects';
+import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue
index 35ae9d8419f..f0c4ecbe3eb 100644
--- a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue
+++ b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue
@@ -1,6 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
-import BitbucketStatusTable from '~/import_projects/components/bitbucket_status_table.vue';
+import BitbucketStatusTable from '~/import_entities/import_projects/components/bitbucket_status_table.vue';
export default {
components: {
diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js
index a44fc4e6b29..a6d748ce857 100644
--- a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js
+++ b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { initStoreFromElement, initPropsFromElement } from '~/import_projects';
+import { initStoreFromElement, initPropsFromElement } from '~/import_entities/import_projects';
import BitbucketServerStatusTable from './components/bitbucket_server_status_table.vue';
document.addEventListener('DOMContentLoaded', () => {
diff --git a/app/assets/javascripts/pages/import/bulk_imports/index.js b/app/assets/javascripts/pages/import/bulk_imports/index.js
new file mode 100644
index 00000000000..37ac1a98466
--- /dev/null
+++ b/app/assets/javascripts/pages/import/bulk_imports/index.js
@@ -0,0 +1,4 @@
+import { mountImportGroupsApp } from '~/import_entities/import_groups';
+
+const mountElement = document.getElementById('import-groups-mount-element');
+mountImportGroupsApp(mountElement);
diff --git a/app/assets/javascripts/pages/import/fogbugz/status/index.js b/app/assets/javascripts/pages/import/fogbugz/status/index.js
index dcd84f0faf9..98ddb8b3aa4 100644
--- a/app/assets/javascripts/pages/import/fogbugz/status/index.js
+++ b/app/assets/javascripts/pages/import/fogbugz/status/index.js
@@ -1,4 +1,4 @@
-import mountImportProjectsTable from '~/import_projects';
+import mountImportProjectsTable from '~/import_entities/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
diff --git a/app/assets/javascripts/pages/import/gitea/status/index.js b/app/assets/javascripts/pages/import/gitea/status/index.js
index dcd84f0faf9..98ddb8b3aa4 100644
--- a/app/assets/javascripts/pages/import/gitea/status/index.js
+++ b/app/assets/javascripts/pages/import/gitea/status/index.js
@@ -1,4 +1,4 @@
-import mountImportProjectsTable from '~/import_projects';
+import mountImportProjectsTable from '~/import_entities/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js
index dcd84f0faf9..98ddb8b3aa4 100644
--- a/app/assets/javascripts/pages/import/github/status/index.js
+++ b/app/assets/javascripts/pages/import/github/status/index.js
@@ -1,4 +1,4 @@
-import mountImportProjectsTable from '~/import_projects';
+import mountImportProjectsTable from '~/import_entities/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
diff --git a/app/assets/javascripts/pages/import/gitlab/status/index.js b/app/assets/javascripts/pages/import/gitlab/status/index.js
index dcd84f0faf9..98ddb8b3aa4 100644
--- a/app/assets/javascripts/pages/import/gitlab/status/index.js
+++ b/app/assets/javascripts/pages/import/gitlab/status/index.js
@@ -1,4 +1,4 @@
-import mountImportProjectsTable from '~/import_projects';
+import mountImportProjectsTable from '~/import_entities/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
diff --git a/app/assets/javascripts/pages/import/manifest/status/index.js b/app/assets/javascripts/pages/import/manifest/status/index.js
index dcd84f0faf9..98ddb8b3aa4 100644
--- a/app/assets/javascripts/pages/import/manifest/status/index.js
+++ b/app/assets/javascripts/pages/import/manifest/status/index.js
@@ -1,4 +1,4 @@
-import mountImportProjectsTable from '~/import_projects';
+import mountImportProjectsTable from '~/import_entities/import_projects';
document.addEventListener('DOMContentLoaded', () => {
const mountElement = document.getElementById('import-projects-mount-element');
diff --git a/app/assets/javascripts/pages/profiles/accounts/show/index.js b/app/assets/javascripts/pages/profiles/accounts/show/index.js
index 96c3d725780..6c1e953aa83 100644
--- a/app/assets/javascripts/pages/profiles/accounts/show/index.js
+++ b/app/assets/javascripts/pages/profiles/accounts/show/index.js
@@ -1,3 +1,6 @@
import initProfileAccount from '~/profile/account';
+import { initClose2faSuccessMessage } from '~/authentication/two_factor_auth';
document.addEventListener('DOMContentLoaded', initProfileAccount);
+
+initClose2faSuccessMessage();
diff --git a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
index 1aeba6669ee..24dbc312dd2 100644
--- a/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
+++ b/app/assets/javascripts/pages/profiles/two_factor_auths/index.js
@@ -1,9 +1,10 @@
import { parseBoolean } from '~/lib/utils/common_utils';
import { mount2faRegistration } from '~/authentication/mount_2fa';
+import { initRecoveryCodes } from '~/authentication/two_factor_auth';
document.addEventListener('DOMContentLoaded', () => {
const twoFactorNode = document.querySelector('.js-two-factor-auth');
- const skippable = parseBoolean(twoFactorNode.dataset.twoFactorSkippable);
+ const skippable = twoFactorNode ? parseBoolean(twoFactorNode.dataset.twoFactorSkippable) : false;
if (skippable) {
const button = `<a class="btn btn-sm btn-warning float-right" data-qa-selector="configure_it_later_button" data-method="patch" href="${twoFactorNode.dataset.two_factor_skip_url}">Configure it later</a>`;
@@ -13,3 +14,5 @@ document.addEventListener('DOMContentLoaded', () => {
mount2faRegistration();
});
+
+initRecoveryCodes();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 1879e263ce7..a96b88732b4 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -6,30 +6,6 @@ import GpgBadges from '~/gpg_badges';
import initWebIdeLink from '~/pages/projects/shared/web_ide_link';
import '~/sourcegraph/load';
import PipelineTourSuccessModal from '~/blob/pipeline_tour_success_modal.vue';
-import { parseBoolean } from '~/lib/utils/common_utils';
-
-const createGitlabCiYmlVisualization = (containerId = '#js-blob-toggle-graph-preview') => {
- const el = document.querySelector(containerId);
- const { isCiConfigFile, blobData } = el?.dataset;
-
- if (el && parseBoolean(isCiConfigFile)) {
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- GitlabCiYamlVisualization: () =>
- import('~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'),
- },
- render(createElement) {
- return createElement('gitlabCiYamlVisualization', {
- props: {
- blobData,
- },
- });
- },
- });
- }
-};
document.addEventListener('DOMContentLoaded', () => {
new BlobViewer(); // eslint-disable-line no-new
@@ -73,25 +49,19 @@ document.addEventListener('DOMContentLoaded', () => {
);
}
- if (gon.features?.suggestPipeline) {
- const successPipelineEl = document.querySelector('.js-success-pipeline-modal');
-
- if (successPipelineEl) {
- // eslint-disable-next-line no-new
- new Vue({
- el: successPipelineEl,
- render(createElement) {
- return createElement(PipelineTourSuccessModal, {
- props: {
- ...successPipelineEl.dataset,
- },
- });
- },
- });
- }
- }
+ const successPipelineEl = document.querySelector('.js-success-pipeline-modal');
- if (gon?.features?.gitlabCiYmlPreview) {
- createGitlabCiYmlVisualization();
+ if (successPipelineEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: successPipelineEl,
+ render(createElement) {
+ return createElement(PipelineTourSuccessModal, {
+ props: {
+ ...successPipelineEl.dataset,
+ },
+ });
+ },
+ });
}
});
diff --git a/app/assets/javascripts/pages/projects/commit/pipelines/index.js b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
index 26dea17ca8a..eaf340f2725 100644
--- a/app/assets/javascripts/pages/projects/commit/pipelines/index.js
+++ b/app/assets/javascripts/pages/projects/commit/pipelines/index.js
@@ -1,8 +1,5 @@
import { initCommitBoxInfo } from '~/projects/commit_box/info';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- initCommitBoxInfo();
-
- initPipelines();
-});
+initCommitBoxInfo();
+initPipelines();
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index e0bd49bf6ef..0750f472341 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -15,35 +15,33 @@ import { __ } from '~/locale';
import loadAwardsHandler from '~/awards_handler';
import { initCommitBoxInfo } from '~/projects/commit_box/info';
-document.addEventListener('DOMContentLoaded', () => {
- const hasPerfBar = document.querySelector('.with-performance-bar');
- const performanceHeight = hasPerfBar ? 35 : 0;
- initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
- new ZenMode();
- new ShortcutsNavigation();
+const hasPerfBar = document.querySelector('.with-performance-bar');
+const performanceHeight = hasPerfBar ? 35 : 0;
+initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
+new ZenMode();
+new ShortcutsNavigation();
- initCommitBoxInfo();
+initCommitBoxInfo();
- initNotes();
+initNotes();
- const filesContainer = $('.js-diffs-batch');
+const filesContainer = $('.js-diffs-batch');
- if (filesContainer.length) {
- const batchPath = filesContainer.data('diffFilesPath');
+if (filesContainer.length) {
+ const batchPath = filesContainer.data('diffFilesPath');
- axios
- .get(batchPath)
- .then(({ data }) => {
- filesContainer.html($(data.html));
- syntaxHighlight(filesContainer);
- handleLocationHash();
- new Diff();
- })
- .catch(() => {
- flash({ message: __('An error occurred while retrieving diff files') });
- });
- } else {
- new Diff();
- }
- loadAwardsHandler();
-});
+ axios
+ .get(batchPath)
+ .then(({ data }) => {
+ filesContainer.html($(data.html));
+ syntaxHighlight(filesContainer);
+ handleLocationHash();
+ new Diff();
+ })
+ .catch(() => {
+ flash({ message: __('An error occurred while retrieving diff files') });
+ });
+} else {
+ new Diff();
+}
+loadAwardsHandler();
diff --git a/app/assets/javascripts/pages/projects/commits/show/index.js b/app/assets/javascripts/pages/projects/commits/show/index.js
index b456baac612..6239e4c99d2 100644
--- a/app/assets/javascripts/pages/projects/commits/show/index.js
+++ b/app/assets/javascripts/pages/projects/commits/show/index.js
@@ -1,12 +1,9 @@
import CommitsList from '~/commits';
import GpgBadges from '~/gpg_badges';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-
import mountCommits from '~/projects/commits';
-document.addEventListener('DOMContentLoaded', () => {
- new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new
- new ShortcutsNavigation(); // eslint-disable-line no-new
- GpgBadges.fetch();
- mountCommits(document.getElementById('js-author-dropdown'));
-});
+new CommitsList(document.querySelector('.js-project-commits-show').dataset.commitsLimit); // eslint-disable-line no-new
+new ShortcutsNavigation(); // eslint-disable-line no-new
+GpgBadges.fetch();
+mountCommits(document.getElementById('js-author-dropdown'));
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
index 11ece478d36..6c0d20c55e9 100644
--- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
+++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue
@@ -85,6 +85,7 @@ export default {
v-model="filter"
:placeholder="$options.i18n.searchPlaceholder"
class="gl-align-self-center gl-ml-auto fork-filtered-search"
+ data-qa-selector="fork_groups_list_search_field"
/>
</template>
</gl-tabs>
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 4b15e435f60..614f8262e5b 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -17,7 +17,8 @@ import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import { IssuableType } from '~/issuable_show/constants';
export default function() {
- const { issueType, ...issuableData } = parseIssuableData();
+ const initialDataEl = document.getElementById('js-issuable-app');
+ const { issueType, ...issuableData } = parseIssuableData(initialDataEl);
switch (issueType) {
case IssuableType.Incident:
diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js
index 1b57c67f16b..ae04d070e62 100644
--- a/app/assets/javascripts/pages/projects/jobs/index/index.js
+++ b/app/assets/javascripts/pages/projects/jobs/index/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+import Tracking from '~/tracking';
document.addEventListener('DOMContentLoaded', () => {
const remainingTimeElements = document.querySelectorAll('.js-remaining-time');
@@ -13,4 +14,13 @@ document.addEventListener('DOMContentLoaded', () => {
},
}),
);
+
+ const trackButtonClick = () => {
+ if (gon.tracking_data) {
+ const { category, action, ...data } = gon.tracking_data;
+ Tracking.event(category, action, data);
+ }
+ };
+ const buttons = document.querySelectorAll('.js-empty-state-button');
+ buttons.forEach(button => button.addEventListener('click', trackButtonClick));
});
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 868e001b182..0714fc21b17 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
@@ -2,7 +2,6 @@ import ZenMode from '~/zen_mode';
import initIssuableSidebar from '~/init_issuable_sidebar';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { handleLocationHash } from '~/lib/utils/common_utils';
-import howToMerge from '~/how_to_merge';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initSourcegraph from '~/sourcegraph';
import loadAwardsHandler from '~/awards_handler';
@@ -15,7 +14,6 @@ export default function() {
initPipelines();
new ShortcutsIssuable(true); // eslint-disable-line no-new
handleLocationHash();
- howToMerge();
initSourcegraph();
loadAwardsHandler();
initInviteMemberModal();
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index 477a1ab887b..19aeb1d1ecf 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -2,46 +2,28 @@ import initProjectVisibilitySelector from '../../../project_visibility';
import initProjectNew from '../../../projects/project_new';
import { __ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
-import Tracking from '~/tracking';
-import { isExperimentEnabled } from '~/lib/utils/experimentation';
document.addEventListener('DOMContentLoaded', () => {
initProjectVisibilitySelector();
initProjectNew.bindEvents();
- const { category, property } = gon.tracking_data ?? { category: 'projects:new' };
- const hasNewCreateProjectUi = isExperimentEnabled('newCreateProjectUi');
+ import(
+ /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation'
+ )
+ .then(m => {
+ const el = document.querySelector('.js-experiment-new-project-creation');
- if (!hasNewCreateProjectUi) {
- // Setting additional tracking for HAML template
+ if (!el) {
+ return;
+ }
- Array.from(
- document.querySelectorAll('.project-edit-container [data-experiment-track-label]'),
- ).forEach(node =>
- node.addEventListener('click', event => {
- const { experimentTrackLabel: label } = event.currentTarget.dataset;
- Tracking.event(category, 'click_tab', { property, label });
- }),
- );
- } else {
- import(
- /* webpackChunkName: 'experiment_new_project_creation' */ '../../../projects/experiment_new_project_creation'
- )
- .then(m => {
- const el = document.querySelector('.js-experiment-new-project-creation');
-
- if (!el) {
- return;
- }
-
- const config = {
- hasErrors: 'hasErrors' in el.dataset,
- isCiCdAvailable: 'isCiCdAvailable' in el.dataset,
- };
- m.default(el, config);
- })
- .catch(() => {
- createFlash(__('An error occurred while loading project creation UI'));
- });
- }
+ const config = {
+ hasErrors: 'hasErrors' in el.dataset,
+ isCiCdAvailable: 'isCiCdAvailable' in el.dataset,
+ };
+ m.default(el, config);
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while loading project creation UI'));
+ });
});
diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js
index bed9a751d4c..63b1f2bf975 100644
--- a/app/assets/javascripts/pages/projects/pipelines/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js
@@ -1,60 +1,3 @@
-import Vue from 'vue';
-import { GlToast } from '@gitlab/ui';
-import { doesHashExistInUrl } from '~/lib/utils/url_utility';
-import {
- parseBoolean,
- historyReplaceState,
- buildUrlWithCurrentLocation,
-} from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
-import PipelinesStore from '../../../../pipelines/stores/pipelines_store';
-import pipelinesComponent from '../../../../pipelines/components/pipelines_list/pipelines.vue';
-import Translate from '../../../../vue_shared/translate';
+import { initPipelinesIndex } from '~/pipelines/pipelines_index';
-Vue.use(Translate);
-Vue.use(GlToast);
-
-document.addEventListener(
- 'DOMContentLoaded',
- () =>
- new Vue({
- el: '#pipelines-list-vue',
- components: {
- pipelinesComponent,
- },
- data() {
- return {
- store: new PipelinesStore(),
- };
- },
- created() {
- this.dataset = document.querySelector(this.$options.el).dataset;
-
- if (doesHashExistInUrl('delete_success')) {
- this.$toast.show(__('The pipeline has been deleted'));
- historyReplaceState(buildUrlWithCurrentLocation());
- }
- },
- render(createElement) {
- return createElement('pipelines-component', {
- props: {
- store: this.store,
- endpoint: this.dataset.endpoint,
- pipelineScheduleUrl: this.dataset.pipelineScheduleUrl,
- helpPagePath: this.dataset.helpPagePath,
- emptyStateSvgPath: this.dataset.emptyStateSvgPath,
- errorStateSvgPath: this.dataset.errorStateSvgPath,
- noPipelinesSvgPath: this.dataset.noPipelinesSvgPath,
- autoDevopsPath: this.dataset.helpAutoDevopsPath,
- newPipelinePath: this.dataset.newPipelinePath,
- canCreatePipeline: parseBoolean(this.dataset.canCreatePipeline),
- hasGitlabCi: parseBoolean(this.dataset.hasGitlabCi),
- ciLintPath: this.dataset.ciLintPath,
- resetCachePath: this.dataset.resetCachePath,
- projectId: this.dataset.projectId,
- params: JSON.parse(this.dataset.params),
- },
- });
- },
- }),
-);
+initPipelinesIndex();
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 5317093c4cf..8c7aa04a0b6 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -9,47 +9,11 @@ import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from '~/flash';
import projectSelect from '../../project_select';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
+import initClonePanel from '~/clone_panel';
export default class Project {
constructor() {
- const $cloneOptions = $('ul.clone-options-dropdown');
- if ($cloneOptions.length) {
- const $projectCloneField = $('#project_clone');
- const $cloneBtnLabel = $('.js-git-clone-holder .js-clone-dropdown-label');
- const mobileCloneField = document.querySelector(
- '.js-mobile-git-clone .js-clone-dropdown-label',
- );
-
- const selectedCloneOption = $cloneBtnLabel.text().trim();
- if (selectedCloneOption.length > 0) {
- $(`a:contains('${selectedCloneOption}')`, $cloneOptions).addClass('is-active');
- }
-
- $('a', $cloneOptions).on('click', e => {
- e.preventDefault();
- const $this = $(e.currentTarget);
- const url = $this.attr('href');
- const cloneType = $this.data('cloneType');
-
- $('.is-active', $cloneOptions).removeClass('is-active');
- $(`a[data-clone-type="${cloneType}"]`).each(function() {
- const $el = $(this);
- const activeText = $el.find('.dropdown-menu-inner-title').text();
- const $container = $el.closest('.project-clone-holder');
- const $label = $container.find('.js-clone-dropdown-label');
-
- $el.toggleClass('is-active');
- $label.text(activeText);
- });
-
- if (mobileCloneField) {
- mobileCloneField.dataset.clipboardText = url;
- } else {
- $projectCloneField.val(url);
- }
- $('.js-git-empty .js-clone').text(url);
- });
- }
+ initClonePanel();
// Ref switcher
if (document.querySelector('.js-project-refs-dropdown')) {
diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
index ae2209b0292..22dddb72f98 100644
--- a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
+++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
@@ -1,3 +1,3 @@
import initExpiresAtField from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', initExpiresAtField);
+initExpiresAtField();
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 d18cde4ac87..83bec0092cb 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -4,6 +4,7 @@ import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list';
import initDeployFreeze from '~/deploy_freeze';
import initSettingsPipelinesTriggers from '~/ci_settings_pipeline_triggers';
+import initSharedRunnersToggle from '~/projects/settings/mount_shared_runners_toggle';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
@@ -32,4 +33,8 @@ document.addEventListener('DOMContentLoaded', () => {
initDeployFreeze();
initSettingsPipelinesTriggers();
+
+ if (gon?.features?.vueifySharedRunnersToggle) {
+ initSharedRunnersToggle();
+ }
});
diff --git a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
index ffc84dc106b..1dc238b56b4 100644
--- a/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
+++ b/app/assets/javascripts/pages/projects/settings/repository/create_deploy_token/index.js
@@ -1,3 +1,3 @@
import initForm from '../form';
-document.addEventListener('DOMContentLoaded', initForm);
+initForm();
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 0f145dbc170..242c58c4981 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
@@ -94,11 +94,7 @@ export default {
{{ optionName }}
</option>
</select>
- <gl-icon
- name="chevron-down"
- aria-hidden="true"
- class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
- />
+ <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index e50add3b0a4..be197a50775 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
@@ -14,6 +14,7 @@ import {
featureAccessLevel,
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone');
@@ -27,7 +28,7 @@ export default {
GlLink,
GlFormCheckbox,
},
- mixins: [settingsMixin],
+ mixins: [settingsMixin, glFeatureFlagsMixin()],
props: {
currentSettings: {
@@ -137,6 +138,7 @@ export default {
snippetsAccessLevel: featureAccessLevel.EVERYONE,
pagesAccessLevel: featureAccessLevel.EVERYONE,
metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
+ analyticsAccessLevel: featureAccessLevel.EVERYONE,
requirementsAccessLevel: featureAccessLevel.EVERYONE,
containerRegistryEnabled: true,
lfsEnabled: true,
@@ -240,6 +242,10 @@ export default {
featureAccessLevel.PROJECT_MEMBERS,
this.metricsDashboardAccessLevel,
);
+ this.analyticsAccessLevel = Math.min(
+ featureAccessLevel.PROJECT_MEMBERS,
+ this.analyticsAccessLevel,
+ );
this.requirementsAccessLevel = Math.min(
featureAccessLevel.PROJECT_MEMBERS,
this.requirementsAccessLevel,
@@ -265,6 +271,8 @@ export default {
this.snippetsAccessLevel = featureAccessLevel.EVERYONE;
if (this.pagesAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.pagesAccessLevel = featureAccessLevel.EVERYONE;
+ if (this.analyticsAccessLevel > featureAccessLevel.NOT_ENABLED)
+ this.analyticsAccessLevel = featureAccessLevel.EVERYONE;
if (this.metricsDashboardAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
this.metricsDashboardAccessLevel = featureAccessLevel.EVERYONE;
if (this.requirementsAccessLevel === featureAccessLevel.PROJECT_MEMBERS)
@@ -341,7 +349,6 @@ export default {
</select>
<gl-icon
name="chevron-down"
- aria-hidden="true"
data-hidden="true"
class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
/>
@@ -495,6 +502,17 @@ export default {
</project-setting-row>
</div>
<project-setting-row
+ ref="analytics-settings"
+ :label="s__('ProjectSettings|Analytics')"
+ :help-text="s__('ProjectSettings|View project analytics')"
+ >
+ <project-feature-setting
+ v-model="analyticsAccessLevel"
+ :options="featureAccessLevelOptions"
+ name="project[project_feature_attributes][analytics_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
v-if="requirementsAvailable"
ref="requirements-settings"
:label="s__('ProjectSettings|Requirements')"
@@ -573,7 +591,6 @@ export default {
</select>
<gl-icon
name="chevron-down"
- aria-hidden="true"
data-hidden="true"
class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
/>
@@ -611,5 +628,24 @@ export default {
}}</template>
</gl-form-checkbox>
</project-setting-row>
+ <project-setting-row
+ v-if="glFeatures.allowEditingCommitMessages"
+ ref="allow-editing-commit-messages"
+ class="gl-mb-4"
+ >
+ <input
+ :value="allowEditingCommitMessages"
+ type="hidden"
+ name="project[project_setting_attributes][allow_editing_commit_messages]"
+ />
+ <gl-form-checkbox v-model="allowEditingCommitMessages">
+ {{ s__('ProjectSettings|Allow editing commit messages') }}
+ <template #help>{{
+ s__(
+ 'ProjectSettings|When enabled, commit authors will be able to edit commit messages on unprotected branches.',
+ )
+ }}</template>
+ </gl-form-checkbox>
+ </project-setting-row>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 413b2d01621..cc676b98e49 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -1,5 +1,5 @@
import initTree from 'ee_else_ce/repository';
-import initBlob from '~/blob_edit/blob_bundle';
+import { initUploadForm } from '~/blob_edit/blob_bundle';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import NotificationsForm from '~/notifications_form';
import UserCallout from '~/user_callout';
@@ -26,7 +26,7 @@ new UserCallout({
// Project show page loads different overview content based on user preferences
const treeSlider = document.getElementById('js-tree-list');
if (treeSlider) {
- initBlob();
+ initUploadForm();
initTree();
}
diff --git a/app/assets/javascripts/pages/search/show/index.js b/app/assets/javascripts/pages/search/show/index.js
index 88f2f30aad9..b6171e08e01 100644
--- a/app/assets/javascripts/pages/search/show/index.js
+++ b/app/assets/javascripts/pages/search/show/index.js
@@ -2,6 +2,6 @@ import Search from './search';
import { initSearchApp } from '~/search';
document.addEventListener('DOMContentLoaded', () => {
- initSearchApp();
- return new Search(); // Deprecated Dropdown (Projects)
+ initSearchApp(); // Vue Bootstrap
+ return new Search(); // Legacy Search Methods
});
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
index 03675f1ce66..b411b637f36 100644
--- a/app/assets/javascripts/pages/search/show/search.js
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -1,57 +1,18 @@
import $ from 'jquery';
import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { deprecatedCreateFlash as Flash } from '~/flash';
-import Api from '~/api';
-import { __ } from '~/locale';
import Project from '~/pages/projects/project';
-import { visitUrl, queryToObject } from '~/lib/utils/url_utility';
+import { visitUrl } from '~/lib/utils/url_utility';
import refreshCounts from './refresh_counts';
export default class Search {
constructor() {
- setHighlightClass(); // Code Highlighting
- const $projectDropdown = $('.js-search-project-dropdown');
-
this.searchInput = '.js-search-input';
this.searchClear = '.js-search-clear';
- const query = queryToObject(window.location.search);
- this.groupId = query?.group_id;
- this.eventListeners();
- refreshCounts();
-
- initDeprecatedJQueryDropdown($projectDropdown, {
- selectable: true,
- filterable: true,
- filterRemote: true,
- fieldName: 'project_id',
- search: {
- fields: ['name'],
- },
- data: (term, callback) => {
- this.getProjectsData(term)
- .then(data => {
- data.unshift({
- name_with_namespace: __('Any'),
- });
- data.splice(1, 0, { type: 'divider' });
-
- return data;
- })
- .then(data => callback(data))
- .catch(() => new Flash(__('Error fetching projects')));
- },
- id(obj) {
- return obj.id;
- },
- text(obj) {
- return obj.name_with_namespace;
- },
- clicked: () => Search.submitSearch(),
- });
-
- Project.initRefSwitcher();
+ setHighlightClass(); // Code Highlighting
+ this.eventListeners(); // Search Form Actions
+ refreshCounts(); // Other Scope Tab Counts
+ Project.initRefSwitcher(); // Code Search Branch Picker
}
eventListeners() {
@@ -97,20 +58,4 @@ export default class Search {
visitUrl($target.href);
ev.stopPropagation();
}
-
- getProjectsData(term) {
- return new Promise(resolve => {
- if (this.groupId) {
- Api.groupProjects(this.groupId, term, {}, resolve);
- } else {
- Api.projects(
- term,
- {
- order_by: 'id',
- },
- resolve,
- );
- }
- });
- }
}
diff --git a/app/assets/javascripts/performance/constants.js b/app/assets/javascripts/performance/constants.js
index 816eb9b3a66..069f3c265f3 100644
--- a/app/assets/javascripts/performance/constants.js
+++ b/app/assets/javascripts/performance/constants.js
@@ -19,16 +19,27 @@ export const SNIPPET_MEASURE_BLOBS_CONTENT = 'snippet-blobs-content';
// Marks
export const WEBIDE_MARK_APP_START = 'webide-app-start';
-export const WEBIDE_MARK_TREE_START = 'webide-tree-start';
-export const WEBIDE_MARK_TREE_FINISH = 'webide-tree-finished';
-export const WEBIDE_MARK_FILE_START = 'webide-file-start';
export const WEBIDE_MARK_FILE_CLICKED = 'webide-file-clicked';
export const WEBIDE_MARK_FILE_FINISH = 'webide-file-finished';
+export const WEBIDE_MARK_REPO_EDITOR_START = 'webide-init-editor-start';
+export const WEBIDE_MARK_REPO_EDITOR_FINISH = 'webide-init-editor-finish';
+export const WEBIDE_MARK_FETCH_BRANCH_DATA_START = 'webide-getBranchData-start';
+export const WEBIDE_MARK_FETCH_BRANCH_DATA_FINISH = 'webide-getBranchData-finish';
+export const WEBIDE_MARK_FETCH_FILE_DATA_START = 'webide-getFileData-start';
+export const WEBIDE_MARK_FETCH_FILE_DATA_FINISH = 'webide-getFileData-finish';
+export const WEBIDE_MARK_FETCH_FILES_START = 'webide-getFiles-start';
+export const WEBIDE_MARK_FETCH_FILES_FINISH = 'webide-getFiles-finish';
+export const WEBIDE_MARK_FETCH_PROJECT_DATA_START = 'webide-getProjectData-start';
+export const WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH = 'webide-getProjectData-finish';
// Measures
-export const WEBIDE_MEASURE_TREE_FROM_REQUEST = 'webide-tree-loading-from-request';
-export const WEBIDE_MEASURE_FILE_FROM_REQUEST = 'webide-file-loading-from-request';
export const WEBIDE_MEASURE_FILE_AFTER_INTERACTION = 'webide-file-loading-after-interaction';
+export const WEBIDE_MEASURE_FETCH_PROJECT_DATA = 'WebIDE: Project data';
+export const WEBIDE_MEASURE_FETCH_BRANCH_DATA = 'WebIDE: Branch data';
+export const WEBIDE_MEASURE_FETCH_FILE_DATA = 'WebIDE: File data';
+export const WEBIDE_MEASURE_BEFORE_VUE = 'WebIDE: Before Vue app';
+export const WEBIDE_MEASURE_REPO_EDITOR = 'WebIDE: Repo Editor';
+export const WEBIDE_MEASURE_FETCH_FILES = 'WebIDE: Fetch Files';
//
// MR Diffs namespace
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index f29b5f42d8f..e0b7f2190ca 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,5 +1,6 @@
/* eslint-disable @gitlab/require-i18n-strings */
import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
import axios from '~/lib/utils/axios_utils';
import PerformanceBarService from './services/performance_bar_service';
@@ -7,6 +8,8 @@ import PerformanceBarStore from './stores/performance_bar_store';
import initPerformanceBarLog from './performance_bar_log';
+Vue.use(Translate);
+
const initPerformanceBar = el => {
const performanceBarData = el.dataset;
@@ -123,11 +126,23 @@ const initPerformanceBar = el => {
});
};
-document.addEventListener('DOMContentLoaded', () => {
+let loadedPeekBar = false;
+function loadBar() {
const jsPeek = document.querySelector('#js-peek');
- if (jsPeek) {
+ if (!loadedPeekBar && jsPeek) {
+ loadedPeekBar = true;
initPerformanceBar(jsPeek);
}
+}
+
+// If js-peek is not loaded when this script is executed, this call will do nothing
+// If this is the case, then it will loadBar on DOMContentLoaded. We would prefer it
+// to be initialized before the DOMContetLoaded event in order to pick up all the
+// requests sent from the page.
+loadBar();
+
+document.addEventListener('DOMContentLoaded', () => {
+ loadBar();
});
initPerformanceBarLog();
diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js
index 8c5f45e9d34..d4857a19ff7 100644
--- a/app/assets/javascripts/persistent_user_callouts.js
+++ b/app/assets/javascripts/persistent_user_callouts.js
@@ -7,6 +7,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-buy-pipeline-minutes-notification-callout',
'.js-token-expiry-callout',
'.js-registration-enabled-callout',
+ '.js-new-user-signups-cap-reached',
];
const initCallouts = () => {
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
new file mode 100644
index 00000000000..9279273283e
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_form.vue
@@ -0,0 +1,139 @@
+<script>
+import {
+ GlButton,
+ GlForm,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormGroup,
+ GlFormTextarea,
+ GlSprintf,
+} from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlForm,
+ GlFormCheckbox,
+ GlFormInput,
+ GlFormGroup,
+ GlFormTextarea,
+ GlSprintf,
+ },
+ props: {
+ defaultBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ defaultMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isSaving: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ message: this.defaultMessage,
+ branch: this.defaultBranch,
+ openMergeRequest: false,
+ };
+ },
+ computed: {
+ isDefaultBranch() {
+ return this.branch === this.defaultBranch;
+ },
+ submitDisabled() {
+ return !(this.message && this.branch);
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.$emit('submit', {
+ message: this.message,
+ branch: this.branch,
+ openMergeRequest: this.openMergeRequest,
+ });
+ },
+ onReset() {
+ this.$emit('cancel');
+ },
+ },
+ i18n: {
+ commitMessage: __('Commit message'),
+ targetBranch: __('Target Branch'),
+ startMergeRequest: __('Start a %{new_merge_request} with these changes'),
+ newMergeRequest: __('new merge request'),
+ commitChanges: __('Commit changes'),
+ cancel: __('Cancel'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
+ <gl-form-group
+ id="commit-group"
+ :label="$options.i18n.commitMessage"
+ label-cols-sm="2"
+ label-for="commit-message"
+ >
+ <gl-form-textarea
+ id="commit-message"
+ v-model="message"
+ class="gl-font-monospace!"
+ required
+ :placeholder="defaultMessage"
+ />
+ </gl-form-group>
+ <gl-form-group
+ id="target-branch-group"
+ :label="$options.i18n.targetBranch"
+ label-cols-sm="2"
+ label-for="target-branch-field"
+ >
+ <gl-form-input
+ id="target-branch-field"
+ v-model="branch"
+ class="gl-font-monospace!"
+ required
+ />
+ <gl-form-checkbox
+ v-if="!isDefaultBranch"
+ v-model="openMergeRequest"
+ data-testid="new-mr-checkbox"
+ class="gl-mt-3"
+ >
+ <gl-sprintf :message="$options.i18n.startMergeRequest">
+ <template #new_merge_request>
+ <strong>{{ $options.i18n.newMergeRequest }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-form-checkbox>
+ </gl-form-group>
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-p-5 gl-bg-gray-10 gl-border-t-gray-100 gl-border-t-solid gl-border-t-1"
+ >
+ <gl-button
+ type="submit"
+ class="js-no-auto-disable"
+ category="primary"
+ variant="success"
+ :disabled="submitDisabled"
+ :loading="isSaving"
+ >
+ {{ $options.i18n.commitChanges }}
+ </gl-button>
+ <gl-button type="reset" category="secondary" class="gl-mr-3">
+ {{ $options.i18n.cancel }}
+ </gl-button>
+ </div>
+ </gl-form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
index 8b37c94de19..0d1c214c5b1 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint_results.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue
@@ -90,7 +90,7 @@ export default {
</script>
<template>
- <div class="col-sm-12 gl-mt-5">
+ <div>
<gl-alert
class="gl-mb-5"
:variant="status.variant"
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue
index 23808bcb292..23808bcb292 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint_results_param.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_param.vue
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue
index 4929c3206df..4929c3206df 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint_results_value.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results_value.vue
diff --git a/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue
index ac0332cb0bd..ac0332cb0bd 100644
--- a/app/assets/javascripts/ci_lint/components/ci_lint_warnings.vue
+++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_warnings.vue
diff --git a/app/assets/javascripts/pipeline_editor/components/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/text_editor.vue
index a925077c906..22f2a32c9ac 100644
--- a/app/assets/javascripts/pipeline_editor/components/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/text_editor.vue
@@ -5,22 +5,10 @@ export default {
components: {
EditorLite,
},
- props: {
- value: {
- type: String,
- required: false,
- default: '',
- },
- },
};
</script>
<template>
<div class="gl-border-solid gl-border-gray-100 gl-border-1">
- <editor-lite
- v-model="value"
- file-name="*.yml"
- :editor-options="{ readOnly: true }"
- @editor-ready="$emit('editor-ready')"
- />
+ <editor-lite file-name="*.yml" v-bind="$attrs" v-on="$listeners" />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
new file mode 100644
index 00000000000..70bab8092c0
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -0,0 +1,2 @@
+export const CI_CONFIG_STATUS_VALID = 'VALID';
+export const CI_CONFIG_STATUS_INVALID = 'INVALID';
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
new file mode 100644
index 00000000000..11bca42fd69
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql
@@ -0,0 +1,26 @@
+mutation commitCIFileMutation(
+ $projectPath: ID!
+ $branch: String!
+ $startBranch: String
+ $message: String!
+ $filePath: String!
+ $lastCommitId: String!
+ $content: String
+) {
+ commitCreate(
+ input: {
+ projectPath: $projectPath
+ branch: $branch
+ startBranch: $startBranch
+ message: $message
+ actions: [
+ { action: UPDATE, filePath: $filePath, lastCommitId: $lastCommitId, content: $content }
+ ]
+ }
+ ) {
+ commit {
+ id
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql
index 496036f690f..496036f690f 100644
--- a/app/assets/javascripts/ci_lint/graphql/mutations/lint_ci.mutation.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/mutations/lint_ci.mutation.graphql
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql
new file mode 100644
index 00000000000..d65d9892260
--- /dev/null
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql
@@ -0,0 +1,11 @@
+#import "~/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql"
+
+query getCiConfigData($content: String!) {
+ ciConfig(content: $content) {
+ errors
+ status
+ stages {
+ ...PipelineStagesConnection
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index 7b8c70ac93e..c1cdb5eb2ee 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -1,4 +1,5 @@
import Api from '~/api';
+import axios from '~/lib/utils/axios_utils';
export const resolvers = {
Query: {
@@ -11,6 +12,32 @@ export const resolvers = {
};
},
},
-};
+ Mutation: {
+ lintCI: (_, { endpoint, content, dry_run }) => {
+ return axios.post(endpoint, { content, dry_run }).then(({ data }) => ({
+ valid: data.valid,
+ errors: data.errors,
+ warnings: data.warnings,
+ jobs: data.jobs.map(job => {
+ const only = job.only ? { refs: job.only.refs, __typename: 'CiLintJobOnlyPolicy' } : null;
-export default resolvers;
+ return {
+ name: job.name,
+ stage: job.stage,
+ beforeScript: job.before_script,
+ script: job.script,
+ afterScript: job.after_script,
+ tagList: job.tag_list,
+ environment: job.environment,
+ when: job.when,
+ allowFailure: job.allow_failure,
+ only,
+ except: job.except,
+ __typename: 'CiLintJob',
+ };
+ }),
+ __typename: 'CiLintContent',
+ }));
+ },
+ },
+};
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index ccd7b74064f..8268a907a29 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -10,7 +10,11 @@ import PipelineEditorApp from './pipeline_editor_app.vue';
export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const el = document.querySelector(selector);
- const { projectPath, defaultBranch, ciConfigPath } = el?.dataset;
+ if (!el) {
+ return null;
+ }
+
+ const { ciConfigPath, commitId, defaultBranch, newMergeRequestPath, projectPath } = el?.dataset;
Vue.use(VueApollo);
@@ -24,9 +28,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
render(h) {
return h(PipelineEditorApp, {
props: {
- projectPath,
- defaultBranch,
ciConfigPath,
+ commitId,
+ defaultBranch,
+ newMergeRequestPath,
+ projectPath,
},
});
},
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 50b946af456..96dc782964b 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -1,21 +1,38 @@
<script>
-import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
+import { mergeUrlParams, redirectTo, refreshCurrentPage } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import TextEditor from './components/text_editor.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
+import CommitForm from './components/commit/commit_form.vue';
+import TextEditor from './components/text_editor.vue';
+import commitCiFileMutation from './graphql/mutations/commit_ci_file.mutation.graphql';
import getBlobContent from './graphql/queries/blob_content.graphql';
+import getCiConfigData from './graphql/queries/ci_config.graphql';
+import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils';
+
+const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
+const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+
+const COMMIT_FAILURE = 'COMMIT_FAILURE';
+const DEFAULT_FAILURE = 'DEFAULT_FAILURE';
+const LOAD_FAILURE_NO_FILE = 'LOAD_FAILURE_NO_FILE';
+const LOAD_FAILURE_NO_REF = 'LOAD_FAILURE_NO_REF';
+const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN';
export default {
components: {
- GlLoadingIcon,
+ CommitForm,
GlAlert,
- GlTabs,
+ GlLoadingIcon,
GlTab,
- TextEditor,
+ GlTabs,
PipelineGraph,
+ TextEditor,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
projectPath: {
type: String,
@@ -26,16 +43,31 @@ export default {
required: false,
default: null,
},
+ commitId: {
+ type: String,
+ required: false,
+ default: null,
+ },
ciConfigPath: {
type: String,
required: true,
},
+ newMergeRequestPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
- error: null,
+ ciConfigData: {},
content: '',
+ contentModel: '',
+ currentTabIndex: 0,
editorIsReady: false,
+ failureType: null,
+ failureReasons: [],
+ isSaving: false,
+ showFailureAlert: false,
};
},
apollo: {
@@ -51,58 +83,212 @@ export default {
update(data) {
return data?.blobContent?.rawData;
},
+ result({ data }) {
+ this.contentModel = data?.blobContent?.rawData ?? '';
+ },
error(error) {
- this.error = error;
+ this.handleBlobContentError(error);
+ },
+ },
+ ciConfigData: {
+ query: getCiConfigData,
+ // If content is not loaded, we can't lint the data
+ skip: ({ contentModel }) => {
+ return !contentModel;
+ },
+ variables() {
+ return {
+ content: this.contentModel,
+ };
+ },
+ update(data) {
+ const { ciConfigData } = data || {};
+ const stageNodes = ciConfigData?.stages?.nodes || [];
+ const stages = unwrapStagesWithNeeds(stageNodes);
+
+ return { ...ciConfigData, stages };
+ },
+ error() {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
},
},
},
computed: {
- loading() {
+ isBlobContentLoading() {
return this.$apollo.queries.content.loading;
},
- errorMessage() {
- const { message: generalReason, networkError } = this.error ?? {};
-
- const { data } = networkError?.response ?? {};
- // 404 for missing file uses `message`
- // 400 for a missing ref uses `error`
- const networkReason = data?.message ?? data?.error;
-
- const reason = networkReason ?? generalReason ?? this.$options.i18n.unknownError;
- return sprintf(this.$options.i18n.errorMessageWithReason, { reason });
+ isVisualizationTabLoading() {
+ return this.$apollo.queries.ciConfigData.loading;
+ },
+ isVisualizeTabActive() {
+ return this.currentTabIndex === 1;
},
- pipelineData() {
- // Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141
- return {};
+ defaultCommitMessage() {
+ return sprintf(this.$options.i18n.defaultCommitMessage, { sourcePath: this.ciConfigPath });
+ },
+ failure() {
+ switch (this.failureType) {
+ case LOAD_FAILURE_NO_REF:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE_NO_REF],
+ variant: 'danger',
+ };
+ case LOAD_FAILURE_NO_FILE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE_NO_FILE],
+ variant: 'danger',
+ };
+ case LOAD_FAILURE_UNKNOWN:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE_UNKNOWN],
+ variant: 'danger',
+ };
+ case COMMIT_FAILURE:
+ return {
+ text: this.$options.errorTexts[COMMIT_FAILURE],
+ variant: 'danger',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT_FAILURE],
+ variant: 'danger',
+ };
+ }
},
},
i18n: {
- unknownError: __('Unknown Error'),
- errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'),
+ defaultCommitMessage: __('Update %{sourcePath} file'),
tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'),
},
+ errorTexts: {
+ [LOAD_FAILURE_NO_REF]: s__(
+ 'Pipelines|Repository does not have a default branch, please set one.',
+ ),
+ [LOAD_FAILURE_NO_FILE]: s__('Pipelines|No CI file found in this repository, please add one.'),
+ [LOAD_FAILURE_UNKNOWN]: s__('Pipelines|The CI configuration was not loaded, please try again.'),
+ [COMMIT_FAILURE]: s__('Pipelines|The GitLab CI configuration could not be updated.'),
+ },
+ methods: {
+ handleBlobContentError(error = {}) {
+ const { networkError } = error;
+
+ const { response } = networkError;
+ if (response?.status === 404) {
+ // 404 for missing CI file
+ this.reportFailure(LOAD_FAILURE_NO_FILE);
+ } else if (response?.status === 400) {
+ // 400 for a missing ref when no default branch is set
+ this.reportFailure(LOAD_FAILURE_NO_REF);
+ } else {
+ this.reportFailure(LOAD_FAILURE_UNKNOWN);
+ }
+ },
+ dismissFailure() {
+ this.showFailureAlert = false;
+ },
+ reportFailure(type, reasons = []) {
+ this.showFailureAlert = true;
+ this.failureType = type;
+ this.failureReasons = reasons;
+ },
+ redirectToNewMergeRequest(sourceBranch) {
+ const url = mergeUrlParams(
+ {
+ [MR_SOURCE_BRANCH]: sourceBranch,
+ [MR_TARGET_BRANCH]: this.defaultBranch,
+ },
+ this.newMergeRequestPath,
+ );
+ redirectTo(url);
+ },
+ async onCommitSubmit(event) {
+ this.isSaving = true;
+ const { message, branch, openMergeRequest } = event;
+
+ try {
+ const {
+ data: {
+ commitCreate: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation: commitCiFileMutation,
+ variables: {
+ projectPath: this.projectPath,
+ branch,
+ startBranch: this.defaultBranch,
+ message,
+ filePath: this.ciConfigPath,
+ content: this.contentModel,
+ lastCommitId: this.commitId,
+ },
+ });
+
+ if (errors?.length) {
+ this.reportFailure(COMMIT_FAILURE, errors);
+ return;
+ }
+
+ if (openMergeRequest) {
+ this.redirectToNewMergeRequest(branch);
+ } else {
+ // Refresh the page to ensure commit is updated
+ refreshCurrentPage();
+ }
+ } catch (error) {
+ this.reportFailure(COMMIT_FAILURE, [error?.message]);
+ } finally {
+ this.isSaving = false;
+ }
+ },
+ onCommitCancel() {
+ this.contentModel = this.content;
+ },
+ },
};
</script>
<template>
<div class="gl-mt-4">
- <gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert>
+ <gl-alert
+ v-if="showFailureAlert"
+ :variant="failure.variant"
+ :dismissible="true"
+ @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>
<div class="gl-mt-4">
- <gl-loading-icon v-if="loading" size="lg" />
- <div v-else class="file-editor">
- <gl-tabs>
+ <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" />
+ <div v-else class="file-editor gl-mb-3">
+ <gl-tabs v-model="currentTabIndex">
<!-- editor should be mounted when its tab is visible, so the container has a size -->
<gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady">
<!-- editor should be mounted only once, when the tab is displayed -->
- <text-editor v-model="content" @editor-ready="editorIsReady = true" />
+ <text-editor v-model="contentModel" @editor-ready="editorIsReady = true" />
</gl-tab>
- <gl-tab :title="$options.i18n.tabGraph">
- <pipeline-graph :pipeline-data="pipelineData" />
+ <gl-tab
+ v-if="glFeatures.ciConfigVisualizationTab"
+ :title="$options.i18n.tabGraph"
+ :lazy="!isVisualizeTabActive"
+ data-testid="visualization-tab"
+ >
+ <gl-loading-icon v-if="isVisualizationTabLoading" size="lg" class="gl-m-3" />
+ <pipeline-graph v-else :pipeline-data="ciConfigData" />
</gl-tab>
</gl-tabs>
</div>
+ <commit-form
+ :default-branch="defaultBranch"
+ :default-message="defaultCommitMessage"
+ :is-saving="isSaving"
+ @cancel="onCommitCancel"
+ @submit="onCommitSubmit"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
index 6552665100a..f2d68054e80 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -12,14 +12,18 @@ import {
GlLink,
GlDropdown,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf,
GlLoadingIcon,
} from '@gitlab/ui';
+import * as Sentry from '~/sentry/wrapper';
import { s__, __, n__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
-import { VARIABLE_TYPE, FILE_TYPE } from '../constants';
+import { VARIABLE_TYPE, FILE_TYPE, CONFIG_VARIABLES_TIMEOUT } from '../constants';
+import { backOff } from '~/lib/utils/common_utils';
+import httpStatusCodes from '~/lib/utils/http_status';
export default {
typeOptions: [
@@ -44,6 +48,7 @@ export default {
GlLink,
GlDropdown,
GlDropdownItem,
+ GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf,
GlLoadingIcon,
@@ -57,11 +62,19 @@ export default {
type: String,
required: true,
},
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
projectId: {
type: String,
required: true,
},
- refs: {
+ branches: {
+ type: Array,
+ required: true,
+ },
+ tags: {
type: Array,
required: true,
},
@@ -92,7 +105,9 @@ export default {
data() {
return {
searchTerm: '',
- refValue: this.refParam,
+ refValue: {
+ shortName: this.refParam,
+ },
form: {},
error: null,
warnings: [],
@@ -102,9 +117,21 @@ export default {
};
},
computed: {
- filteredRefs() {
- const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
- return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm));
+ lowerCasedSearchTerm() {
+ return this.searchTerm.toLowerCase();
+ },
+ filteredBranches() {
+ return this.branches.filter(branch =>
+ branch.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
+ );
+ },
+ filteredTags() {
+ return this.tags.filter(tag =>
+ tag.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
+ );
+ },
+ hasTags() {
+ return this.tags.length > 0;
},
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
@@ -118,14 +145,27 @@ export default {
shouldShowWarning() {
return this.warnings.length > 0 && !this.isWarningDismissed;
},
+ refShortName() {
+ return this.refValue.shortName;
+ },
+ refFullName() {
+ return this.refValue.fullName;
+ },
variables() {
- return this.form[this.refValue]?.variables ?? [];
+ return this.form[this.refFullName]?.variables ?? [];
},
descriptions() {
- return this.form[this.refValue]?.descriptions ?? {};
+ return this.form[this.refFullName]?.descriptions ?? {};
},
},
created() {
+ // this is needed until we add support for ref type in url query strings
+ // ensure default branch is called with full ref on load
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/287815
+ if (this.refValue.shortName === this.defaultBranch) {
+ this.refValue.fullName = `refs/heads/${this.refValue.shortName}`;
+ }
+
this.setRefSelected(this.refValue);
},
methods: {
@@ -168,19 +208,19 @@ export default {
setRefSelected(refValue) {
this.refValue = refValue;
- if (!this.form[refValue]) {
- this.fetchConfigVariables(refValue)
+ if (!this.form[this.refFullName]) {
+ this.fetchConfigVariables(this.refFullName || this.refShortName)
.then(({ descriptions, params }) => {
- Vue.set(this.form, refValue, {
+ Vue.set(this.form, this.refFullName, {
variables: [],
descriptions,
});
// Add default variables from yml
- this.setVariableParams(refValue, VARIABLE_TYPE, params);
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
})
.catch(() => {
- Vue.set(this.form, refValue, {
+ Vue.set(this.form, this.refFullName, {
variables: [],
descriptions: {},
});
@@ -188,20 +228,19 @@ export default {
.finally(() => {
// Add/update variables, e.g. from query string
if (this.variableParams) {
- this.setVariableParams(refValue, VARIABLE_TYPE, this.variableParams);
+ this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
}
if (this.fileParams) {
- this.setVariableParams(refValue, FILE_TYPE, this.fileParams);
+ this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
}
// Adds empty var at the end of the form
- this.addEmptyVariable(refValue);
+ this.addEmptyVariable(this.refFullName);
});
}
},
-
isSelected(ref) {
- return ref === this.refValue;
+ return ref.fullName === this.refValue.fullName;
},
removeVariable(index) {
this.variables.splice(index, 1);
@@ -209,34 +248,52 @@ export default {
canRemove(index) {
return index < this.variables.length - 1;
},
-
fetchConfigVariables(refValue) {
- if (gon?.features?.newPipelineFormPrefilledVars) {
- this.isLoading = true;
+ if (!gon?.features?.newPipelineFormPrefilledVars) {
+ return Promise.resolve({ params: {}, descriptions: {} });
+ }
+
+ this.isLoading = true;
- return axios
+ return backOff((next, stop) => {
+ axios
.get(this.configVariablesPath, {
params: {
sha: refValue,
},
})
- .then(({ data }) => {
- const params = {};
- const descriptions = {};
+ .then(({ data, status }) => {
+ if (status === httpStatusCodes.NO_CONTENT) {
+ next();
+ } else {
+ this.isLoading = false;
+ stop(data);
+ }
+ })
+ .catch(error => {
+ stop(error);
+ });
+ }, CONFIG_VARIABLES_TIMEOUT)
+ .then(data => {
+ const params = {};
+ const descriptions = {};
- Object.entries(data).forEach(([key, { value, description }]) => {
- if (description !== null) {
- params[key] = value;
- descriptions[key] = description;
- }
- });
+ Object.entries(data).forEach(([key, { value, description }]) => {
+ if (description !== null) {
+ params[key] = value;
+ descriptions[key] = description;
+ }
+ });
- this.isLoading = false;
+ return { params, descriptions };
+ })
+ .catch(error => {
+ this.isLoading = false;
- return { params, descriptions };
- });
- }
- return Promise.resolve({ params: {}, descriptions: {} });
+ Sentry.captureException(error);
+
+ return { params: {}, descriptions: {} };
+ });
},
createPipeline() {
const filteredVariables = this.variables
@@ -249,7 +306,9 @@ export default {
return axios
.post(this.pipelinesPath, {
- ref: this.refValue,
+ // send shortName as fall back for query params
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/287815
+ ref: this.refValue.fullName || this.refShortName,
variables_attributes: filteredVariables,
})
.then(({ data }) => {
@@ -307,20 +366,29 @@ export default {
</details>
</gl-alert>
<gl-form-group :label="s__('Pipeline|Run for')">
- <gl-dropdown :text="refValue" block>
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- :placeholder="__('Search branches and tags')"
- />
+ <gl-dropdown :text="refShortName" block>
+ <gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search refs')" />
+ <gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="branch in filteredBranches"
+ :key="branch.fullName"
+ class="gl-font-monospace"
+ is-check-item
+ :is-checked="isSelected(branch)"
+ @click="setRefSelected(branch)"
+ >
+ {{ branch.shortName }}
+ </gl-dropdown-item>
+ <gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
<gl-dropdown-item
- v-for="(ref, index) in filteredRefs"
- :key="index"
+ v-for="tag in filteredTags"
+ :key="tag.fullName"
class="gl-font-monospace"
is-check-item
- :is-checked="isSelected(ref)"
- @click="setRefSelected(ref)"
+ :is-checked="isSelected(tag)"
+ @click="setRefSelected(tag)"
>
- {{ ref }}
+ {{ tag.shortName }}
</gl-dropdown-item>
</gl-dropdown>
@@ -353,7 +421,7 @@ export default {
:placeholder="s__('CiVariables|Input variable key')"
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-key"
- @change="addEmptyVariable(refValue)"
+ @change="addEmptyVariable(refFullName)"
/>
<gl-form-input
v-model="variable.value"
diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js
index b4ab1143f60..004bbe7daf4 100644
--- a/app/assets/javascripts/pipeline_new/constants.js
+++ b/app/assets/javascripts/pipeline_new/constants.js
@@ -1,2 +1,5 @@
export const VARIABLE_TYPE = 'env_var';
export const FILE_TYPE = 'file';
+export const CONFIG_VARIABLES_TIMEOUT = 5000;
+export const BRANCH_REF_TYPE = 'branch';
+export const TAG_REF_TYPE = 'tag';
diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js
index ff4f677654e..0b85184ec90 100644
--- a/app/assets/javascripts/pipeline_new/index.js
+++ b/app/assets/javascripts/pipeline_new/index.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import PipelineNewForm from './components/pipeline_new_form.vue';
+import formatRefs from './utils/format_refs';
export default () => {
const el = document.getElementById('js-new-pipeline');
@@ -7,17 +8,20 @@ export default () => {
projectId,
pipelinesPath,
configVariablesPath,
+ defaultBranch,
refParam,
varParam,
fileParam,
- refNames,
+ branchRefs,
+ tagRefs,
settingsLink,
maxWarnings,
} = el?.dataset;
const variableParams = JSON.parse(varParam);
const fileParams = JSON.parse(fileParam);
- const refs = JSON.parse(refNames);
+ const branches = formatRefs(JSON.parse(branchRefs), 'branch');
+ const tags = formatRefs(JSON.parse(tagRefs), 'tag');
return new Vue({
el,
@@ -27,10 +31,12 @@ export default () => {
projectId,
pipelinesPath,
configVariablesPath,
+ defaultBranch,
refParam,
variableParams,
fileParams,
- refs,
+ branches,
+ tags,
settingsLink,
maxWarnings: Number(maxWarnings),
},
diff --git a/app/assets/javascripts/pipeline_new/utils/format_refs.js b/app/assets/javascripts/pipeline_new/utils/format_refs.js
new file mode 100644
index 00000000000..e217cd25413
--- /dev/null
+++ b/app/assets/javascripts/pipeline_new/utils/format_refs.js
@@ -0,0 +1,18 @@
+import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '../constants';
+
+export default (refs, type) => {
+ let fullName;
+
+ return refs.map(ref => {
+ if (type === BRANCH_REF_TYPE) {
+ fullName = `refs/heads/${ref}`;
+ } else if (type === TAG_REF_TYPE) {
+ fullName = `refs/tags/${ref}`;
+ }
+
+ return {
+ shortName: ref,
+ fullName,
+ };
+ });
+};
diff --git a/app/assets/javascripts/pipelines/components/graph/accessors.js b/app/assets/javascripts/pipelines/components/graph/accessors.js
new file mode 100644
index 00000000000..6ece855bcd8
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/accessors.js
@@ -0,0 +1,25 @@
+import { get } from 'lodash';
+import { REST, GRAPHQL } from './constants';
+
+const accessors = {
+ [REST]: {
+ detailsPath: 'details_path',
+ groupId: 'id',
+ hasDetails: 'has_details',
+ pipelineStatus: ['details', 'status'],
+ sourceJob: ['source_job', 'name'],
+ },
+ [GRAPHQL]: {
+ detailsPath: 'detailsPath',
+ groupId: 'name',
+ hasDetails: 'hasDetails',
+ pipelineStatus: 'status',
+ sourceJob: ['sourceJob', 'name'],
+ },
+};
+
+const accessValue = (dataMethod, prop, item) => {
+ return get(item, accessors[dataMethod][prop]);
+};
+
+export { accessors, accessValue };
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index a580ee11627..4e9b21a5c55 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -87,10 +87,10 @@ export default {
:title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
- class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
+ class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center"
@click.stop="onClickAction"
>
<gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
- <gl-icon v-else :name="actionIcon" class="gl-mr-0!" />
+ <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js
index ba1922b6dae..6f0deccfef6 100644
--- a/app/assets/javascripts/pipelines/components/graph/constants.js
+++ b/app/assets/javascripts/pipelines/components/graph/constants.js
@@ -1,3 +1,6 @@
export const DOWNSTREAM = 'downstream';
export const MAIN = 'main';
export const UPSTREAM = 'upstream';
+
+export const REST = 'rest';
+export const GRAPHQL = 'graphql';
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 16ce279a591..67b2ed3b596 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,35 +1,23 @@
<script>
-import { escape, capitalize } from 'lodash';
-import { GlLoadingIcon } from '@gitlab/ui';
-import StageColumnComponent from './stage_column_component.vue';
-import GraphWidthMixin from '../../mixins/graph_width_mixin';
+import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
-import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
-import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
+import StageColumnComponent from './stage_column_component.vue';
+import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
export default {
name: 'PipelineGraph',
components: {
- StageColumnComponent,
- GlLoadingIcon,
+ LinkedGraphWrapper,
LinkedPipelinesColumn,
+ StageColumnComponent,
},
- mixins: [GraphWidthMixin, GraphBundleMixin],
props: {
- isLoading: {
- type: Boolean,
- required: true,
- },
- pipeline: {
- type: Object,
- required: true,
- },
isLinkedPipeline: {
type: Boolean,
required: false,
default: false,
},
- mediator: {
+ pipeline: {
type: Object,
required: true,
},
@@ -39,12 +27,13 @@ export default {
default: MAIN,
},
},
- upstream: UPSTREAM,
- downstream: DOWNSTREAM,
+ pipelineTypeConstants: {
+ DOWNSTREAM,
+ UPSTREAM,
+ },
data() {
return {
- downstreamMarginTop: null,
- jobName: null,
+ hoveredJobName: '',
pipelineExpanded: {
jobName: '',
expanded: false,
@@ -52,219 +41,86 @@ export default {
};
},
computed: {
+ downstreamPipelines() {
+ return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
+ },
graph() {
- return this.pipeline.details?.stages;
+ return this.pipeline.stages;
},
- hasUpstream() {
- return (
- this.type !== this.$options.downstream &&
- this.upstreamPipelines &&
- this.pipeline.triggered_by !== null
- );
+ hasDownstreamPipelines() {
+ return Boolean(this.pipeline?.downstream?.length > 0);
},
- upstreamPipelines() {
- return this.pipeline.triggered_by;
+ hasUpstreamPipelines() {
+ return Boolean(this.pipeline?.upstream?.length > 0);
},
- hasDownstream() {
+ // The two show checks prevent upstream / downstream from showing redundant linked columns
+ showDownstreamPipelines() {
return (
- this.type !== this.$options.upstream &&
- this.downstreamPipelines &&
- this.pipeline.triggered.length > 0
+ this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM
);
},
- downstreamPipelines() {
- return this.pipeline.triggered;
- },
- expandedUpstream() {
+ showUpstreamPipelines() {
return (
- this.pipeline.triggered_by &&
- Array.isArray(this.pipeline.triggered_by) &&
- this.pipeline.triggered_by.find(el => el.isExpanded)
+ this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM
);
},
- expandedDownstream() {
- return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
- },
- pipelineTypeUpstream() {
- return this.type !== this.$options.downstream && this.expandedUpstream;
- },
- pipelineTypeDownstream() {
- return this.type !== this.$options.upstream && this.expandedDownstream;
- },
- pipelineProjectId() {
- return this.pipeline.project.id;
+ upstreamPipelines() {
+ return this.hasUpstreamPipelines ? this.pipeline.upstream : [];
},
},
methods: {
- capitalizeStageName(name) {
- const escapedName = escape(name);
- return capitalize(escapedName);
- },
- isFirstColumn(index) {
- return index === 0;
- },
- stageConnectorClass(index, stage) {
- let className;
-
- // If it's the first stage column and only has one job
- if (this.isFirstColumn(index) && stage.groups.length === 1) {
- className = 'no-margin';
- } else if (index > 0) {
- // If it is not the first column
- className = 'left-margin';
- }
-
- return className;
- },
- refreshPipelineGraph() {
- this.$emit('refreshPipelineGraph');
- },
- /**
- * CSS class is applied:
- * - if pipeline graph contains only one stage column component
- *
- * @param {number} index
- * @returns {boolean}
- */
- shouldAddRightMargin(index) {
- return !(index === this.graph.length - 1);
- },
- handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
- /**
- * Calculates the margin top of the clicked downstream pipeline by
- * subtracting the clicked downstream pipelines offsetTop by it's parent's
- * offsetTop and then subtracting 15
- */
- this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15);
-
- /**
- * If the expanded trigger is defined and the id is different than the
- * pipeline we clicked, then it means we clicked on a sibling downstream link
- * and we want to reset the pipeline store. Triggering the reset without
- * this condition would mean not allowing downstreams of downstreams to expand
- */
- if (this.expandedDownstream?.id !== pipeline.id) {
- this.$emit('onResetDownstream', this.pipeline, pipeline);
- }
-
- this.$emit('onClickDownstreamPipeline', pipeline);
- },
- calculateMarginTop(downstreamNode, pixelDiff) {
- return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`;
- },
- hasOnlyOneJob(stage) {
- return stage.groups.length === 1;
- },
- hasUpstreamColumn(index) {
- return index === 0 && this.hasUpstream;
- },
setJob(jobName) {
- this.jobName = jobName;
+ this.hoveredJobName = jobName;
},
- setPipelineExpanded(jobName, expanded) {
- if (expanded) {
- this.pipelineExpanded = {
- jobName,
- expanded,
- };
- } else {
- this.pipelineExpanded = {
- expanded,
- jobName: '',
- };
- }
+ togglePipelineExpanded(jobName, expanded) {
+ this.pipelineExpanded = {
+ expanded,
+ jobName: expanded ? jobName : '',
+ };
},
},
};
</script>
<template>
- <div class="build-content middle-block js-pipeline-graph">
+ <div class="js-pipeline-graph">
<div
- class="pipeline-visualization pipeline-graph"
- :class="{ 'pipeline-tab-content': !isLinkedPipeline }"
+ class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap"
+ :class="{ 'gl-py-5': !isLinkedPipeline }"
>
- <div
- :style="{
- paddingLeft: `${graphLeftPadding}px`,
- paddingRight: `${graphRightPadding}px`,
- }"
- >
- <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" />
-
- <pipeline-graph
- v-if="pipelineTypeUpstream"
- :type="$options.upstream"
- class="d-inline-block upstream-pipeline"
- :class="`js-upstream-pipeline-${expandedUpstream.id}`"
- :is-loading="false"
- :pipeline="expandedUpstream"
- :is-linked-pipeline="true"
- :mediator="mediator"
- @onClickUpstreamPipeline="clickUpstreamPipeline"
- @refreshPipelineGraph="requestRefreshPipelineGraph"
- />
-
- <linked-pipelines-column
- v-if="hasUpstream"
- :type="$options.upstream"
- :linked-pipelines="upstreamPipelines"
- :column-title="__('Upstream')"
- :project-id="pipelineProjectId"
- @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
- />
-
- <ul
- v-if="!isLoading"
- :class="{
- 'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
- }"
- class="stage-column-list align-top"
- >
+ <linked-graph-wrapper>
+ <template #upstream>
+ <linked-pipelines-column
+ v-if="showUpstreamPipelines"
+ :linked-pipelines="upstreamPipelines"
+ :column-title="__('Upstream')"
+ :type="$options.pipelineTypeConstants.UPSTREAM"
+ @error="emit('error', errorType)"
+ />
+ </template>
+ <template #main>
<stage-column-component
- v-for="(stage, index) in graph"
+ v-for="stage in graph"
:key="stage.name"
- :class="{
- 'has-upstream gl-ml-11': hasUpstreamColumn(index),
- 'has-only-one-job': hasOnlyOneJob(stage),
- 'gl-mr-26': shouldAddRightMargin(index),
- }"
- :title="capitalizeStageName(stage.name)"
+ :title="stage.name"
:groups="stage.groups"
- :stage-connector-class="stageConnectorClass(index, stage)"
- :is-first-column="isFirstColumn(index)"
- :has-upstream="hasUpstream"
:action="stage.status.action"
- :job-hovered="jobName"
+ :job-hovered="hoveredJobName"
:pipeline-expanded="pipelineExpanded"
- @refreshPipelineGraph="refreshPipelineGraph"
+ @refreshPipelineGraph="$emit('refreshPipelineGraph')"
/>
- </ul>
-
- <linked-pipelines-column
- v-if="hasDownstream"
- :type="$options.downstream"
- :linked-pipelines="downstreamPipelines"
- :column-title="__('Downstream')"
- :project-id="pipelineProjectId"
- @linkedPipelineClick="handleClickedDownstream"
- @downstreamHovered="setJob"
- @pipelineExpandToggle="setPipelineExpanded"
- />
-
- <pipeline-graph
- v-if="pipelineTypeDownstream"
- :type="$options.downstream"
- class="d-inline-block"
- :class="`js-downstream-pipeline-${expandedDownstream.id}`"
- :is-loading="false"
- :pipeline="expandedDownstream"
- :is-linked-pipeline="true"
- :style="{ 'margin-top': downstreamMarginTop }"
- :mediator="mediator"
- @onClickDownstreamPipeline="clickDownstreamPipeline"
- @refreshPipelineGraph="requestRefreshPipelineGraph"
- />
- </div>
+ </template>
+ <template #downstream>
+ <linked-pipelines-column
+ v-if="showDownstreamPipelines"
+ :linked-pipelines="downstreamPipelines"
+ :column-title="__('Downstream')"
+ :type="$options.pipelineTypeConstants.DOWNSTREAM"
+ @downstreamHovered="setJob"
+ @pipelineExpandToggle="togglePipelineExpanded"
+ @error="emit('error', errorType)"
+ />
+ </template>
+ </linked-graph-wrapper>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
new file mode 100644
index 00000000000..9ca4dc1e27a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
@@ -0,0 +1,265 @@
+<script>
+import { escape, capitalize } from 'lodash';
+import { GlLoadingIcon } from '@gitlab/ui';
+import StageColumnComponentLegacy from './stage_column_component_legacy.vue';
+import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue';
+import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
+import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
+
+export default {
+ name: 'PipelineGraphLegacy',
+ components: {
+ GlLoadingIcon,
+ LinkedPipelinesColumnLegacy,
+ StageColumnComponentLegacy,
+ },
+ mixins: [GraphBundleMixin],
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ isLinkedPipeline: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: false,
+ default: MAIN,
+ },
+ },
+ upstream: UPSTREAM,
+ downstream: DOWNSTREAM,
+ data() {
+ return {
+ downstreamMarginTop: null,
+ jobName: null,
+ pipelineExpanded: {
+ jobName: '',
+ expanded: false,
+ },
+ };
+ },
+ computed: {
+ graph() {
+ return this.pipeline.details?.stages;
+ },
+ hasUpstream() {
+ return (
+ this.type !== this.$options.downstream &&
+ this.upstreamPipelines &&
+ this.pipeline.triggered_by !== null
+ );
+ },
+ upstreamPipelines() {
+ return this.pipeline.triggered_by;
+ },
+ hasDownstream() {
+ return (
+ this.type !== this.$options.upstream &&
+ this.downstreamPipelines &&
+ this.pipeline.triggered.length > 0
+ );
+ },
+ downstreamPipelines() {
+ return this.pipeline.triggered;
+ },
+ expandedUpstream() {
+ return (
+ this.pipeline.triggered_by &&
+ Array.isArray(this.pipeline.triggered_by) &&
+ this.pipeline.triggered_by.find(el => el.isExpanded)
+ );
+ },
+ expandedDownstream() {
+ return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
+ },
+ pipelineTypeUpstream() {
+ return this.type !== this.$options.downstream && this.expandedUpstream;
+ },
+ pipelineTypeDownstream() {
+ return this.type !== this.$options.upstream && this.expandedDownstream;
+ },
+ pipelineProjectId() {
+ return this.pipeline.project.id;
+ },
+ },
+ methods: {
+ capitalizeStageName(name) {
+ const escapedName = escape(name);
+ return capitalize(escapedName);
+ },
+ isFirstColumn(index) {
+ return index === 0;
+ },
+ stageConnectorClass(index, stage) {
+ let className;
+
+ // If it's the first stage column and only has one job
+ if (this.isFirstColumn(index) && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
+
+ return className;
+ },
+ refreshPipelineGraph() {
+ this.$emit('refreshPipelineGraph');
+ },
+ /**
+ * CSS class is applied:
+ * - if pipeline graph contains only one stage column component
+ *
+ * @param {number} index
+ * @returns {boolean}
+ */
+ shouldAddRightMargin(index) {
+ return !(index === this.graph.length - 1);
+ },
+ handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
+ /**
+ * Calculates the margin top of the clicked downstream pipeline by
+ * subtracting the clicked downstream pipelines offsetTop by it's parent's
+ * offsetTop and then subtracting 15
+ */
+ this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15);
+
+ /**
+ * If the expanded trigger is defined and the id is different than the
+ * pipeline we clicked, then it means we clicked on a sibling downstream link
+ * and we want to reset the pipeline store. Triggering the reset without
+ * this condition would mean not allowing downstreams of downstreams to expand
+ */
+ if (this.expandedDownstream?.id !== pipeline.id) {
+ this.$emit('onResetDownstream', this.pipeline, pipeline);
+ }
+
+ this.$emit('onClickDownstreamPipeline', pipeline);
+ },
+ calculateMarginTop(downstreamNode, pixelDiff) {
+ return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`;
+ },
+ hasOnlyOneJob(stage) {
+ return stage.groups.length === 1;
+ },
+ hasUpstreamColumn(index) {
+ return index === 0 && this.hasUpstream;
+ },
+ setJob(jobName) {
+ this.jobName = jobName;
+ },
+ setPipelineExpanded(jobName, expanded) {
+ if (expanded) {
+ this.pipelineExpanded = {
+ jobName,
+ expanded,
+ };
+ } else {
+ this.pipelineExpanded = {
+ expanded,
+ jobName: '',
+ };
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div class="build-content middle-block js-pipeline-graph">
+ <div
+ class="pipeline-visualization pipeline-graph"
+ :class="{ 'pipeline-tab-content': !isLinkedPipeline }"
+ >
+ <div class="gl-w-full">
+ <div class="container-fluid container-limited">
+ <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" />
+ <pipeline-graph-legacy
+ v-if="pipelineTypeUpstream"
+ :type="$options.upstream"
+ class="d-inline-block upstream-pipeline"
+ :class="`js-upstream-pipeline-${expandedUpstream.id}`"
+ :is-loading="false"
+ :pipeline="expandedUpstream"
+ :is-linked-pipeline="true"
+ :mediator="mediator"
+ @onClickUpstreamPipeline="clickUpstreamPipeline"
+ @refreshPipelineGraph="requestRefreshPipelineGraph"
+ />
+
+ <linked-pipelines-column-legacy
+ v-if="hasUpstream"
+ :type="$options.upstream"
+ :linked-pipelines="upstreamPipelines"
+ :column-title="__('Upstream')"
+ :project-id="pipelineProjectId"
+ @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
+ />
+
+ <ul
+ v-if="!isLoading"
+ :class="{
+ 'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
+ }"
+ class="stage-column-list align-top"
+ >
+ <stage-column-component-legacy
+ v-for="(stage, index) in graph"
+ :key="stage.name"
+ :class="{
+ 'has-upstream gl-ml-11': hasUpstreamColumn(index),
+ 'has-only-one-job': hasOnlyOneJob(stage),
+ 'gl-mr-26': shouldAddRightMargin(index),
+ }"
+ :title="capitalizeStageName(stage.name)"
+ :groups="stage.groups"
+ :stage-connector-class="stageConnectorClass(index, stage)"
+ :is-first-column="isFirstColumn(index)"
+ :has-upstream="hasUpstream"
+ :action="stage.status.action"
+ :job-hovered="jobName"
+ :pipeline-expanded="pipelineExpanded"
+ @refreshPipelineGraph="refreshPipelineGraph"
+ />
+ </ul>
+
+ <linked-pipelines-column-legacy
+ v-if="hasDownstream"
+ :type="$options.downstream"
+ :linked-pipelines="downstreamPipelines"
+ :column-title="__('Downstream')"
+ :project-id="pipelineProjectId"
+ @linkedPipelineClick="handleClickedDownstream"
+ @downstreamHovered="setJob"
+ @pipelineExpandToggle="setPipelineExpanded"
+ />
+
+ <pipeline-graph-legacy
+ v-if="pipelineTypeDownstream"
+ :type="$options.downstream"
+ class="d-inline-block"
+ :class="`js-downstream-pipeline-${expandedDownstream.id}`"
+ :is-loading="false"
+ :pipeline="expandedDownstream"
+ :is-linked-pipeline="true"
+ :style="{ 'margin-top': downstreamMarginTop }"
+ :mediator="mediator"
+ @onClickDownstreamPipeline="clickDownstreamPipeline"
+ @refreshPipelineGraph="requestRefreshPipelineGraph"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
new file mode 100644
index 00000000000..d98e3aad054
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { DEFAULT, LOAD_FAILURE } from '../../constants';
+import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
+import PipelineGraph from './graph_component.vue';
+import { unwrapPipelineData, toggleQueryPollingByVisibility } from './utils';
+
+export default {
+ name: 'PipelineGraphWrapper',
+ components: {
+ GlAlert,
+ GlLoadingIcon,
+ PipelineGraph,
+ },
+ inject: {
+ pipelineIid: {
+ default: '',
+ },
+ pipelineProjectPath: {
+ default: '',
+ },
+ },
+ data() {
+ return {
+ pipeline: null,
+ alertType: null,
+ showAlert: false,
+ };
+ },
+ errorTexts: {
+ [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'),
+ [DEFAULT]: __('An unknown error occurred while loading this graph.'),
+ },
+ apollo: {
+ pipeline: {
+ query: getPipelineDetails,
+ pollInterval: 10000,
+ variables() {
+ return {
+ projectPath: this.pipelineProjectPath,
+ iid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ return unwrapPipelineData(this.pipelineProjectPath, data);
+ },
+ error() {
+ this.reportFailure(LOAD_FAILURE);
+ },
+ },
+ },
+ computed: {
+ alert() {
+ switch (this.alertType) {
+ case LOAD_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE],
+ variant: 'danger',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ variant: 'danger',
+ };
+ }
+ },
+ showLoadingIcon() {
+ /*
+ Shows the icon only when the graph is empty, not when it is is
+ being refetched, for instance, on action completion
+ */
+ return this.$apollo.queries.pipeline.loading && !this.pipeline;
+ },
+ },
+ mounted() {
+ toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
+ },
+ methods: {
+ hideAlert() {
+ this.showAlert = false;
+ },
+ refreshPipelineGraph() {
+ this.$apollo.queries.pipeline.refetch();
+ },
+ reportFailure(type) {
+ this.showAlert = true;
+ this.failureType = type;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
+ {{ alert.text }}
+ </gl-alert>
+ <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
+ <pipeline-graph
+ v-if="pipeline"
+ :pipeline="pipeline"
+ @error="reportFailure"
+ @refreshPipelineGraph="refreshPipelineGraph"
+ />
+ </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 49591a80752..203d6a12edd 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -44,17 +44,18 @@ export default {
type="button"
data-toggle="dropdown"
data-display="static"
- class="dropdown-menu-toggle build-content"
+ class="dropdown-menu-toggle build-content gl-build-content"
>
- <ci-icon :status="group.status" />
+ <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" />
+ <span class="gl-text-truncate mw-70p gl-pl-3">
+ {{ group.name }}
+ </span>
+ </span>
- <span
- class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom"
- >
- {{ group.name }}
- </span>
-
- <span class="dropdown-counter-badge"> {{ group.size }} </span>
+ <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span>
+ </div>
</button>
<ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 4ed0aae0d1e..93ebe02d4e8 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -4,6 +4,8 @@ import ActionComponent from './action_component.vue';
import JobNameComponent from './job_name_component.vue';
import { sprintf } from '~/locale';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import { accessValue } from './accessors';
+import { REST } from './constants';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
@@ -41,6 +43,11 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [delayedJobMixin],
+ inject: {
+ dataMethod: {
+ default: REST,
+ },
+ },
props: {
job: {
type: Object,
@@ -71,10 +78,15 @@ export default {
boundary() {
return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
},
+ detailsPath() {
+ return accessValue(this.dataMethod, 'detailsPath', this.status);
+ },
+ hasDetails() {
+ return accessValue(this.dataMethod, 'hasDetails', this.status);
+ },
status() {
return this.job && this.job.status ? this.job.status : {};
},
-
tooltipText() {
const textBuilder = [];
const { name: jobName } = this.job;
@@ -129,19 +141,23 @@ export default {
};
</script>
<template>
- <div class="ci-job-component" data-qa-selector="job_item_container">
+ <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="status.has_details"
+ v-if="hasDetails"
v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
- :href="status.details_path"
+ :href="detailsPath"
:title="tooltipText"
:class="jobClasses"
- class="js-pipeline-graph-job-link qa-job-link menu-item"
+ 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" />
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
</gl-link>
<div
@@ -149,11 +165,11 @@ export default {
v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
:title="tooltipText"
:class="jobClasses"
- class="js-job-component-tooltip non-details-job-component"
+ 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" />
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
</div>
<action-component
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index 1b71949784a..23a38fc053e 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -16,18 +16,22 @@ export default {
type: String,
required: true,
},
-
status: {
type: Object,
required: true,
},
+ iconSize: {
+ type: Number,
+ required: false,
+ default: 16,
+ },
},
};
</script>
<template>
- <span class="ci-job-name-component mw-100">
- <ci-icon :status="status" />
- <span class="gl-text-truncate mw-70p gl-pl-2 gl-display-inline-block gl-vertical-align-bottom">
+ <span class="ci-job-name-component mw-100 gl-display-flex gl-align-items-center">
+ <ci-icon :size="iconSize" :status="status" />
+ <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block">
{{ name }}
</span>
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 11f06a25984..1a179de64cd 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -2,7 +2,8 @@
import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
import { __, sprintf } from '~/locale';
-import { UPSTREAM, DOWNSTREAM } from './constants';
+import { accessValue } from './accessors';
+import { DOWNSTREAM, REST, UPSTREAM } from './constants';
export default {
directives: {
@@ -14,28 +15,43 @@ export default {
GlLink,
GlLoadingIcon,
},
+ inject: {
+ dataMethod: {
+ default: REST,
+ },
+ },
props: {
columnTitle: {
type: String,
required: true,
},
- pipeline: {
- type: Object,
+ expanded: {
+ type: Boolean,
required: true,
},
- projectId: {
- type: Number,
+ pipeline: {
+ type: Object,
required: true,
},
type: {
type: String,
required: true,
},
- },
- data() {
- return {
- expanded: false,
- };
+ /*
+ The next two props will be removed or required
+ once the graph transition is done.
+ See: https://gitlab.com/gitlab-org/gitlab/-/issues/291043
+ */
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ projectId: {
+ type: Number,
+ required: false,
+ default: -1,
+ },
},
computed: {
tooltipText() {
@@ -46,7 +62,7 @@ export default {
return `js-linked-pipeline-${this.pipeline.id}`;
},
pipelineStatus() {
- return this.pipeline.details.status;
+ return accessValue(this.dataMethod, 'pipelineStatus', this.pipeline);
},
projectName() {
return this.pipeline.project.name;
@@ -68,6 +84,9 @@ export default {
}
return __('Multi-project');
},
+ pipelineIsLoading() {
+ return Boolean(this.isLoading || this.pipeline.isLoading);
+ },
isDownstream() {
return this.type === DOWNSTREAM;
},
@@ -75,12 +94,15 @@ export default {
return this.type === UPSTREAM;
},
isSameProject() {
- return this.projectId === this.pipeline.project.id;
+ return this.projectId > -1
+ ? this.projectId === this.pipeline.project.id
+ : !this.pipeline.multiproject;
+ },
+ sourceJobName() {
+ return accessValue(this.dataMethod, 'sourceJob', this.pipeline);
},
sourceJobInfo() {
- return this.isDownstream
- ? sprintf(__('Created by %{job}'), { job: this.pipeline.source_job.name })
- : '';
+ return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : '';
},
expandedIcon() {
if (this.isUpstream) {
@@ -94,16 +116,15 @@ export default {
},
methods: {
onClickLinkedPipeline() {
- this.$root.$emit('bv::hide::tooltip', this.buttonId);
- this.expanded = !this.expanded;
+ this.hideTooltips();
this.$emit('pipelineClicked', this.$refs.linkedPipeline);
- this.$emit('pipelineExpandToggle', this.pipeline.source_job.name, this.expanded);
+ this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded);
},
hideTooltips() {
this.$root.$emit('bv::hide::tooltip');
},
onDownstreamHovered() {
- this.$emit('downstreamHovered', this.pipeline.source_job.name);
+ this.$emit('downstreamHovered', this.sourceJobName);
},
onDownstreamHoverLeave() {
this.$emit('downstreamHovered', '');
@@ -113,10 +134,10 @@ export default {
</script>
<template>
- <li
+ <div
ref="linkedPipeline"
v-gl-tooltip
- class="linked-pipeline build"
+ class="linked-pipeline build gl-pipeline-job-width"
:title="tooltipText"
:class="{ 'downstream-pipeline': isDownstream }"
data-qa-selector="child_pipeline"
@@ -129,8 +150,9 @@ export default {
>
<div class="gl-display-flex">
<ci-status
- v-if="!pipeline.isLoading"
+ v-if="!pipelineIsLoading"
:status="pipelineStatus"
+ :size="24"
css-classes="gl-top-0 gl-pr-2"
/>
<div v-else class="gl-pr-2"><gl-loading-icon inline /></div>
@@ -153,10 +175,10 @@ 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"
- data-testid="expandPipelineButton"
+ data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline"
/>
</div>
- </li>
+ </div>
</template>
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 2ca33e6d33e..7d333087874 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -1,10 +1,14 @@
<script>
+import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
import LinkedPipeline from './linked_pipeline.vue';
+import { LOAD_FAILURE } from '../../constants';
import { UPSTREAM } from './constants';
+import { unwrapPipelineData, toggleQueryPollingByVisibility } from './utils';
export default {
components: {
LinkedPipeline,
+ PipelineGraph: () => import('./graph_component.vue'),
},
props: {
columnTitle: {
@@ -19,11 +23,22 @@ export default {
type: String,
required: true,
},
- projectId: {
- type: Number,
- required: true,
- },
},
+ data() {
+ return {
+ currentPipeline: null,
+ loadingPipelineId: null,
+ pipelineExpanded: false,
+ };
+ },
+ titleClasses: [
+ 'gl-font-weight-bold',
+ 'gl-pipeline-job-width',
+ 'gl-text-truncate',
+ 'gl-line-height-36',
+ 'gl-pl-3',
+ 'gl-mb-5',
+ ],
computed: {
columnClass() {
const positionValues = {
@@ -35,14 +50,69 @@ export default {
graphPosition() {
return this.isUpstream ? 'left' : 'right';
},
- // Refactor string match when BE returns Upstream/Downstream indicators
isUpstream() {
return this.type === UPSTREAM;
},
+ computedTitleClasses() {
+ const positionalClasses = this.isUpstream
+ ? ['gl-w-full', 'gl-text-right', 'gl-linked-pipeline-padding']
+ : [];
+
+ return [...this.$options.titleClasses, ...positionalClasses];
+ },
},
methods: {
- onPipelineClick(downstreamNode, pipeline, index) {
- this.$emit('linkedPipelineClick', pipeline, index, downstreamNode);
+ getPipelineData(pipeline) {
+ const projectPath = pipeline.project.fullPath;
+
+ this.$apollo.addSmartQuery('currentPipeline', {
+ query: getPipelineDetails,
+ pollInterval: 10000,
+ variables() {
+ return {
+ projectPath,
+ iid: pipeline.iid,
+ };
+ },
+ update(data) {
+ return unwrapPipelineData(projectPath, data);
+ },
+ result() {
+ this.loadingPipelineId = null;
+ },
+ error() {
+ this.$emit('error', LOAD_FAILURE);
+ },
+ });
+
+ toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline);
+ },
+ isExpanded(id) {
+ return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id);
+ },
+ isLoadingPipeline(id) {
+ return this.loadingPipelineId === id;
+ },
+ onPipelineClick(pipeline) {
+ /* If the clicked pipeline has been expanded already, close it, clear, exit */
+ if (this.currentPipeline?.id === pipeline.id) {
+ this.pipelineExpanded = false;
+ this.currentPipeline = null;
+ return;
+ }
+
+ /* Set the loading id */
+ this.loadingPipelineId = pipeline.id;
+
+ /*
+ Expand the pipeline.
+ If this was not a toggle close action, and
+ it was already showing a different pipeline, then
+ this will be a no-op, but that doesn't matter.
+ */
+ this.pipelineExpanded = true;
+
+ this.getPipelineData(pipeline);
},
onDownstreamHovered(jobName) {
this.$emit('downstreamHovered', jobName);
@@ -60,25 +130,40 @@ export default {
</script>
<template>
- <div :class="columnClass" class="stage-column linked-pipelines-column">
- <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
- <div v-if="isUpstream" class="cross-project-triangle"></div>
- <ul>
- <linked-pipeline
- v-for="(pipeline, index) in linkedPipelines"
- :key="pipeline.id"
- :class="{
- active: pipeline.isExpanded,
- 'left-connector': pipeline.isExpanded && graphPosition === 'left',
- }"
- :pipeline="pipeline"
- :column-title="columnTitle"
- :project-id="projectId"
- :type="type"
- @pipelineClicked="onPipelineClick($event, pipeline, index)"
- @downstreamHovered="onDownstreamHovered"
- @pipelineExpandToggle="onPipelineExpandToggle"
- />
- </ul>
+ <div class="gl-display-flex">
+ <div :class="columnClass" class="linked-pipelines-column">
+ <div data-testid="linked-column-title" class="stage-name" :class="computedTitleClasses">
+ {{ columnTitle }}
+ </div>
+ <ul class="gl-pl-0">
+ <li
+ v-for="pipeline in linkedPipelines"
+ :key="pipeline.id"
+ class="gl-display-flex gl-mb-4"
+ :class="{ 'gl-flex-direction-row-reverse': isUpstream }"
+ >
+ <linked-pipeline
+ class="gl-display-inline-block"
+ :is-loading="isLoadingPipeline(pipeline.id)"
+ :pipeline="pipeline"
+ :column-title="columnTitle"
+ :type="type"
+ :expanded="isExpanded(pipeline.id)"
+ @downstreamHovered="onDownstreamHovered"
+ @pipelineClicked="onPipelineClick(pipeline)"
+ @pipelineExpandToggle="onPipelineExpandToggle"
+ />
+ <div v-if="isExpanded(pipeline.id)" class="gl-display-inline-block">
+ <pipeline-graph
+ v-if="currentPipeline"
+ :type="type"
+ class="d-inline-block gl-mt-n2"
+ :pipeline="currentPipeline"
+ :is-linked-pipeline="true"
+ />
+ </div>
+ </li>
+ </ul>
+ </div>
</div>
</template>
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
new file mode 100644
index 00000000000..7d371b33220
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
@@ -0,0 +1,87 @@
+<script>
+import LinkedPipeline from './linked_pipeline.vue';
+import { UPSTREAM } from './constants';
+
+export default {
+ components: {
+ LinkedPipeline,
+ },
+ props: {
+ columnTitle: {
+ type: String,
+ required: true,
+ },
+ linkedPipelines: {
+ type: Array,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ columnClass() {
+ const positionValues = {
+ right: 'gl-ml-11',
+ left: 'gl-mr-7',
+ };
+ return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
+ },
+ graphPosition() {
+ return this.isUpstream ? 'left' : 'right';
+ },
+ isExpanded() {
+ return this.pipeline?.isExpanded || false;
+ },
+ isUpstream() {
+ return this.type === UPSTREAM;
+ },
+ },
+ methods: {
+ onPipelineClick(downstreamNode, pipeline, index) {
+ this.$emit('linkedPipelineClick', pipeline, index, downstreamNode);
+ },
+ onDownstreamHovered(jobName) {
+ this.$emit('downstreamHovered', jobName);
+ },
+ onPipelineExpandToggle(jobName, expanded) {
+ // Highlighting only applies to downstream pipelines
+ if (this.isUpstream) {
+ return;
+ }
+
+ this.$emit('pipelineExpandToggle', jobName, expanded);
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="columnClass" class="stage-column linked-pipelines-column">
+ <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
+ <div v-if="isUpstream" class="cross-project-triangle"></div>
+ <ul>
+ <li v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id">
+ <linked-pipeline
+ :class="{
+ active: pipeline.isExpanded,
+ 'left-connector': pipeline.isExpanded && graphPosition === 'left',
+ }"
+ :pipeline="pipeline"
+ :column-title="columnTitle"
+ :project-id="projectId"
+ :type="type"
+ :expanded="isExpanded"
+ @pipelineClicked="onPipelineClick($event, pipeline, index)"
+ @downstreamHovered="onDownstreamHovered"
+ @pipelineExpandToggle="onPipelineExpandToggle"
+ />
+ </li>
+ </ul>
+ </div>
+</template>
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 a75ec585b95..b9bddc94ce4 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,17 +1,19 @@
<script>
-import { isEmpty, escape } from 'lodash';
-import stageColumnMixin from '../../mixins/stage_column_mixin';
+import { capitalize, escape, isEmpty } from 'lodash';
+import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import ActionComponent from './action_component.vue';
+import { GRAPHQL } from './constants';
+import { accessValue } from './accessors';
export default {
components: {
- JobItem,
- JobGroupDropdown,
ActionComponent,
+ JobGroupDropdown,
+ JobItem,
+ MainGraphWrapper,
},
- mixins: [stageColumnMixin],
props: {
title: {
type: String,
@@ -21,16 +23,6 @@ export default {
type: Array,
required: true,
},
- isFirstColumn: {
- type: Boolean,
- required: false,
- default: false,
- },
- stageConnectorClass: {
- type: String,
- required: false,
- default: '',
- },
action: {
type: Object,
required: false,
@@ -47,62 +39,68 @@ export default {
default: () => ({}),
},
},
+ titleClasses: [
+ 'gl-font-weight-bold',
+ 'gl-pipeline-job-width',
+ 'gl-text-truncate',
+ 'gl-line-height-36',
+ 'gl-pl-3',
+ ],
computed: {
+ formattedTitle() {
+ return capitalize(escape(this.title));
+ },
hasAction() {
return !isEmpty(this.action);
},
},
methods: {
+ getGroupId(group) {
+ return accessValue(GRAPHQL, 'groupId', group);
+ },
groupId(group) {
return `ci-badge-${escape(group.name)}`;
},
- pipelineActionRequestComplete() {
- this.$emit('refreshPipelineGraph');
- },
},
};
</script>
<template>
- <li :class="stageConnectorClass" class="stage-column">
- <div class="stage-name position-relative">
- {{ title }}
- <action-component
- v-if="hasAction"
- :action-icon="action.icon"
- :tooltip-text="action.title"
- :link="action.path"
- class="js-stage-action stage-action rounded"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
- </div>
-
- <div class="builds-container">
- <ul>
- <li
- v-for="(group, index) in groups"
- :id="groupId(group)"
- :key="group.id"
- :class="buildConnnectorClass(index)"
- class="build"
- >
- <div class="curve"></div>
-
- <job-item
- v-if="group.size === 1"
- :job="group.jobs[0]"
- :job-hovered="jobHovered"
- :pipeline-expanded="pipelineExpanded"
- css-class-job-name="build-content"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
-
- <job-group-dropdown
- v-if="group.size > 1"
- :group="group"
- @pipelineActionRequestComplete="pipelineActionRequestComplete"
- />
- </li>
- </ul>
- </div>
- </li>
+ <main-graph-wrapper>
+ <template #stages>
+ <div
+ data-testid="stage-column-title"
+ class="gl-display-flex gl-justify-content-space-between gl-relative"
+ :class="$options.titleClasses"
+ >
+ <div>{{ formattedTitle }}</div>
+ <action-component
+ v-if="hasAction"
+ :action-icon="action.icon"
+ :tooltip-text="action.title"
+ :link="action.path"
+ class="js-stage-action stage-action rounded"
+ @pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
+ />
+ </div>
+ </template>
+ <template #jobs>
+ <div
+ v-for="group in groups"
+ :id="groupId(group)"
+ :key="getGroupId(group)"
+ data-testid="stage-column-group"
+ class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width"
+ >
+ <job-item
+ v-if="group.size === 1"
+ :job="group.jobs[0]"
+ :job-hovered="jobHovered"
+ :pipeline-expanded="pipelineExpanded"
+ css-class-job-name="gl-build-content"
+ @pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
+ />
+ <job-group-dropdown v-else :group="group" />
+ </div>
+ </template>
+ </main-graph-wrapper>
</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
new file mode 100644
index 00000000000..258b6bf6b6d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
@@ -0,0 +1,108 @@
+<script>
+import { isEmpty, escape } from 'lodash';
+import stageColumnMixin from '../../mixins/stage_column_mixin';
+import JobItem from './job_item.vue';
+import JobGroupDropdown from './job_group_dropdown.vue';
+import ActionComponent from './action_component.vue';
+
+export default {
+ components: {
+ JobItem,
+ JobGroupDropdown,
+ ActionComponent,
+ },
+ mixins: [stageColumnMixin],
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ groups: {
+ type: Array,
+ required: true,
+ },
+ isFirstColumn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ stageConnectorClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ action: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ jobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ pipelineExpanded: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ hasAction() {
+ return !isEmpty(this.action);
+ },
+ },
+ methods: {
+ groupId(group) {
+ return `ci-badge-${escape(group.name)}`;
+ },
+ pipelineActionRequestComplete() {
+ this.$emit('refreshPipelineGraph');
+ },
+ },
+};
+</script>
+<template>
+ <li :class="stageConnectorClass" class="stage-column">
+ <div class="stage-name position-relative" data-testid="stage-column-title">
+ {{ title }}
+ <action-component
+ v-if="hasAction"
+ :action-icon="action.icon"
+ :tooltip-text="action.title"
+ :link="action.path"
+ class="js-stage-action stage-action rounded"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </div>
+
+ <div class="builds-container">
+ <ul>
+ <li
+ v-for="(group, index) in groups"
+ :id="groupId(group)"
+ :key="group.id"
+ :class="buildConnnectorClass(index)"
+ class="build"
+ >
+ <div class="curve"></div>
+
+ <job-item
+ v-if="group.size === 1"
+ :job="group.jobs[0]"
+ :job-hovered="jobHovered"
+ :pipeline-expanded="pipelineExpanded"
+ css-class-job-name="build-content"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+
+ <job-group-dropdown
+ v-if="group.size > 1"
+ :group="group"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </li>
+ </ul>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
new file mode 100644
index 00000000000..32588feb426
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -0,0 +1,57 @@
+import Visibility from 'visibilityjs';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { unwrapStagesWithNeeds } from '../unwrapping_utils';
+
+const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
+ return {
+ ...linkedPipeline,
+ multiproject: mainPipelineProjectPath !== linkedPipeline.project.fullPath,
+ };
+};
+
+const transformId = linkedPipeline => {
+ return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) };
+};
+
+const unwrapPipelineData = (mainPipelineProjectPath, data) => {
+ if (!data?.project?.pipeline) {
+ return null;
+ }
+
+ const { pipeline } = data.project;
+
+ const {
+ upstream,
+ downstream,
+ stages: { nodes: stages },
+ } = pipeline;
+
+ const nodes = unwrapStagesWithNeeds(stages);
+
+ return {
+ ...pipeline,
+ id: getIdFromGraphQLId(pipeline.id),
+ stages: nodes,
+ upstream: upstream
+ ? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
+ : [],
+ downstream: downstream
+ ? downstream.nodes.map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
+ : [],
+ };
+};
+
+const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => {
+ const stopStartQuery = query => {
+ if (!Visibility.hidden()) {
+ query.startPolling(interval);
+ } else {
+ query.stopPolling();
+ }
+ };
+
+ stopStartQuery(queryRef);
+ Visibility.change(stopStartQuery.bind(null, queryRef));
+};
+
+export { unwrapPipelineData, toggleQueryPollingByVisibility };
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue b/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue
new file mode 100644
index 00000000000..fb2280d971a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph_shared/linked_graph_wrapper.vue
@@ -0,0 +1,7 @@
+<template>
+ <div class="gl-display-flex">
+ <slot name="upstream"></slot>
+ <slot name="main"></slot>
+ <slot name="downstream"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue b/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue
new file mode 100644
index 00000000000..1c9e3236d56
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue
@@ -0,0 +1,32 @@
+<script>
+export default {
+ props: {
+ stageClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ jobClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div
+ class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-mb-5"
+ :class="stageClasses"
+ >
+ <slot name="stages"> </slot>
+ </div>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
+ :class="jobClasses"
+ >
+ <slot name="jobs"> </slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 741609c908a..af7c0d0ec3f 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -229,6 +229,7 @@ export default {
v-if="pipeline.cancelable"
:loading="isCanceling"
:disabled="isCanceling"
+ class="gl-ml-3"
variant="danger"
data-testid="cancelPipeline"
@click="cancelPipeline()"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
index 45940d4a39c..35230e1511b 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js
@@ -1,5 +1,5 @@
import * as d3 from 'd3';
-import { createUniqueJobId } from '../../utils';
+import { createUniqueLinkId } from '../../utils';
/**
* This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`,
@@ -12,13 +12,13 @@ import { createUniqueJobId } from '../../utils';
* @returns {Array} Links that contain all the information about them
*/
-export const generateLinksData = ({ links }, jobs, containerID) => {
+export const generateLinksData = ({ links }, containerID) => {
const containerEl = document.getElementById(containerID);
return links.map(link => {
const path = d3.path();
- const sourceId = jobs[link.source].id;
- const targetId = jobs[link.target].id;
+ const sourceId = link.source;
+ const targetId = link.target;
const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId);
@@ -89,7 +89,7 @@ export const generateLinksData = ({ links }, jobs, containerID) => {
...link,
source: sourceId,
target: targetId,
- ref: createUniqueJobId(sourceId, targetId),
+ ref: createUniqueLinkId(sourceId, targetId),
path: path.toString(),
};
});
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue
deleted file mode 100644
index 3cc76425e2a..00000000000
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue
+++ /dev/null
@@ -1,76 +0,0 @@
-<script>
-import { GlTab, GlTabs } from '@gitlab/ui';
-import jsYaml from 'js-yaml';
-import PipelineGraph from './pipeline_graph.vue';
-import { preparePipelineGraphData } from '../../utils';
-
-export default {
- FILE_CONTENT_SELECTOR: '#blob-content',
- EMPTY_FILE_SELECTOR: '.nothing-here-block',
-
- components: {
- GlTab,
- GlTabs,
- PipelineGraph,
- },
- props: {
- blobData: {
- required: true,
- type: String,
- },
- },
- data() {
- return {
- selectedTabIndex: 0,
- pipelineData: {},
- };
- },
- computed: {
- isVisualizationTab() {
- return this.selectedTabIndex === 1;
- },
- },
- async created() {
- if (this.blobData) {
- // The blobData in this case represents the gitlab-ci.yml data
- const json = await jsYaml.load(this.blobData);
- this.pipelineData = preparePipelineGraphData(json);
- }
- },
- methods: {
- // This is used because the blob page still uses haml, and we can't make
- // our haml hide the unused section so we resort to a standard query here.
- toggleFileContent({ isFileTab }) {
- const el = document.querySelector(this.$options.FILE_CONTENT_SELECTOR);
- const emptySection = document.querySelector(this.$options.EMPTY_FILE_SELECTOR);
-
- const elementToHide = el || emptySection;
-
- if (!elementToHide) {
- return;
- }
-
- // Checking for the current style display prevents user
- // from toggling visiblity on and off when clicking on the tab
- if (!isFileTab && elementToHide.style.display !== 'none') {
- elementToHide.style.display = 'none';
- }
-
- if (isFileTab && elementToHide.style.display === 'none') {
- elementToHide.style.display = 'block';
- }
- },
- },
-};
-</script>
-<template>
- <div>
- <div>
- <gl-tabs v-model="selectedTabIndex">
- <gl-tab :title="__('File')" @click="toggleFileContent({ isFileTab: true })" />
- <gl-tab :title="__('Visualization')" @click="toggleFileContent({ isFileTab: false })" />
- </gl-tabs>
- </div>
- <pipeline-graph v-if="isVisualizationTab" :pipeline-data="pipelineData" />
- </div>
-</template>
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 a0c35f54c0e..51a95612d3f 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -10,10 +10,6 @@ export default {
type: String,
required: true,
},
- jobId: {
- type: String,
- required: true,
- },
isHighlighted: {
type: Boolean,
required: false,
@@ -45,7 +41,7 @@ export default {
},
methods: {
onMouseEnter() {
- this.$emit('on-mouse-enter', this.jobId);
+ this.$emit('on-mouse-enter', this.jobName);
},
onMouseLeave() {
this.$emit('on-mouse-leave');
@@ -56,7 +52,7 @@ export default {
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
- :id="jobId"
+ :id="jobName"
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 11ad2f2a3b6..73e5f2542fb 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -6,8 +6,10 @@ import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils';
-import { DRAW_FAILURE, DEFAULT } from '../../constants';
-import { generateJobNeedsDict } from '../../utils';
+import { unwrapArrayOfJobs } from '../unwrapping_utils';
+import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
+import { createJobsHash, generateJobNeedsDict } from '../../utils';
+import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
export default {
components: {
@@ -22,6 +24,12 @@ export default {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
},
+ warningTexts: {
+ [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: {
required: true,
@@ -40,18 +48,51 @@ export default {
},
computed: {
isPipelineDataEmpty() {
- return isEmpty(this.pipelineData);
+ return !this.isInvalidCiConfig && isEmpty(this.pipelineData?.stages);
+ },
+ isInvalidCiConfig() {
+ return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
+ },
+ showAlert() {
+ return this.hasError || this.hasWarning;
},
hasError() {
return this.failureType;
},
+ hasWarning() {
+ return this.warning;
+ },
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
+ alert() {
+ if (this.hasError) {
+ return this.failure;
+ }
+
+ return this.warning;
+ },
failure() {
const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
- return { text, variant: 'danger' };
+ return { text, variant: 'danger', dismissible: true };
+ },
+ warning() {
+ if (this.isPipelineDataEmpty) {
+ return {
+ text: this.$options.warningTexts[EMPTY_PIPELINE_DATA],
+ variant: 'tip',
+ dismissible: false,
+ };
+ } else if (this.isInvalidCiConfig) {
+ return {
+ text: this.$options.warningTexts[INVALID_CI_CONFIG],
+ variant: 'danger',
+ dismissible: false,
+ };
+ }
+
+ return null;
},
viewBox() {
return [0, 0, this.width, this.height];
@@ -80,19 +121,21 @@ export default {
},
},
mounted() {
- if (!this.isPipelineDataEmpty) {
- this.getGraphDimensions();
- this.drawJobLinks();
+ if (!this.isPipelineDataEmpty && !this.isInvalidCiConfig) {
+ // This guarantee that all sub-elements are rendered
+ // https://v3.vuejs.org/api/options-lifecycle-hooks.html#mounted
+ this.$nextTick(() => {
+ this.getGraphDimensions();
+ this.prepareLinkData();
+ });
}
},
methods: {
- drawJobLinks() {
- const { stages, jobs } = this.pipelineData;
- const unwrappedGroups = this.unwrapPipelineData(stages);
-
+ prepareLinkData() {
try {
- const parsedData = parseData(unwrappedGroups);
- this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID);
+ const arrayOfJobs = unwrapArrayOfJobs(this.pipelineData);
+ const parsedData = parseData(arrayOfJobs);
+ this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID);
} catch {
this.reportFailure(DRAW_FAILURE);
}
@@ -119,7 +162,8 @@ export default {
// The first time we hover, we create the object where
// we store all the data to properly highlight the needs.
if (!this.needsObject) {
- this.needsObject = generateJobNeedsDict(this.pipelineData) ?? {};
+ const jobs = createJobsHash(this.pipelineData);
+ this.needsObject = generateJobNeedsDict(jobs) ?? {};
}
this.highlightedJob = uniqueJobId;
@@ -127,18 +171,9 @@ export default {
removeHighlightNeeds() {
this.highlightedJob = null;
},
- unwrapPipelineData(stages) {
- return stages
- .map(({ name, groups }) => {
- return groups.map(group => {
- return { category: name, ...group };
- });
- })
- .flat(2);
- },
getGraphDimensions() {
- this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`;
- this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`;
+ this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`;
+ this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`;
},
reportFailure(errorType) {
this.failureType = errorType;
@@ -163,21 +198,20 @@ export default {
</script>
<template>
<div>
- <gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure">
- {{ failure.text }}
- </gl-alert>
- <gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
- {{
- __(
- 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
- )
- }}
+ <gl-alert
+ v-if="showAlert"
+ :variant="alert.variant"
+ :dismissible="alert.dismissible"
+ @dismiss="alert.dismissible ? resetFailure : null"
+ >
+ {{ alert.text }}
</gl-alert>
<div
- v-else
+ v-if="!hasWarning"
: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">
<template>
@@ -210,10 +244,9 @@ export default {
<job-pill
v-for="group in stage.groups"
:key="group.name"
- :job-id="group.id"
:job-name="group.name"
- :is-highlighted="hasHighlightedJob && isJobHighlighted(group.id)"
- :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.id)"
+ :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
+ :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
@on-mouse-enter="highlightNeeds"
@on-mouse-leave="removeHighlightNeeds"
/>
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 c5f30c8aef0..78b69073cd3 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -29,11 +29,13 @@ export default {
</div>
<div class="col-12">
- <div class="text-content">
+ <div class="gl-text-content">
<template v-if="canSetCi">
- <h4 class="text-center">{{ s__('Pipelines|Build with confidence') }}</h4>
+ <h4 class="gl-text-center" data-testid="header-text">
+ {{ s__('Pipelines|Build with confidence') }}
+ </h4>
- <p>
+ <p data-testid="info-text">
{{
s__(`Pipelines|Continuous Integration can help
catch bugs by running your tests automatically,
@@ -42,12 +44,11 @@ export default {
}}
</p>
- <div class="text-center">
+ <div class="gl-text-center">
<gl-button
:href="helpPagePath"
variant="info"
category="primary"
- class="js-get-started-pipelines"
data-testid="get-started-pipelines"
>
{{ s__('Pipelines|Get started with Pipelines') }}
@@ -55,7 +56,7 @@ export default {
</div>
</template>
- <p v-else class="text-center">
+ <p v-else class="gl-text-center">
{{ s__('Pipelines|This project is not currently set up to run pipelines.') }}
</p>
</div>
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 63262cc79fd..bde0dd53aac 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -25,6 +25,11 @@ export default {
required: true,
},
},
+ inject: {
+ targetProjectFullPath: {
+ default: '',
+ },
+ },
computed: {
user() {
return this.pipeline.user;
@@ -32,6 +37,12 @@ export default {
isScheduled() {
return this.pipeline.source === SCHEDULE_ORIGIN;
},
+ isInFork() {
+ return Boolean(
+ this.targetProjectFullPath &&
+ this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`,
+ );
+ },
},
};
</script>
@@ -52,9 +63,8 @@ export default {
:title="__('This pipeline was triggered by a schedule.')"
class="badge badge-info"
data-testid="pipeline-url-scheduled"
+ >{{ __('Scheduled') }}</span
>
- {{ __('Scheduled') }}
- </span>
</gl-link>
<span
v-if="pipeline.flags.latest"
@@ -62,27 +72,24 @@ export default {
:title="__('Latest pipeline for the most recent commit on this branch')"
class="js-pipeline-url-latest badge badge-success"
data-testid="pipeline-url-latest"
+ >{{ __('latest') }}</span
>
- {{ __('latest') }}
- </span>
<span
v-if="pipeline.flags.yaml_errors"
v-gl-tooltip
:title="pipeline.yaml_errors"
class="js-pipeline-url-yaml badge badge-danger"
data-testid="pipeline-url-yaml"
+ >{{ __('yaml invalid') }}</span
>
- {{ __('yaml invalid') }}
- </span>
<span
v-if="pipeline.flags.failure_reason"
v-gl-tooltip
:title="pipeline.failure_reason"
class="js-pipeline-url-failure badge badge-danger"
data-testid="pipeline-url-failure"
+ >{{ __('error') }}</span
>
- {{ __('error') }}
- </span>
<gl-link
v-if="pipeline.flags.auto_devops"
:id="`pipeline-url-autodevops-${pipeline.id}`"
@@ -112,17 +119,16 @@ export default {
</gl-sprintf>
</div>
</template>
- <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">
- {{ __('Learn more about Auto DevOps') }}
- </gl-link>
+ <gl-link :href="autoDevopsHelpPath" target="_blank" rel="noopener noreferrer nofollow">{{
+ __('Learn more about Auto DevOps')
+ }}</gl-link>
</gl-popover>
<span
v-if="pipeline.flags.stuck"
class="js-pipeline-url-stuck badge badge-warning"
data-testid="pipeline-url-stuck"
+ >{{ __('stuck') }}</span
>
- {{ __('stuck') }}
- </span>
<span
v-if="pipeline.flags.detached_merge_request_pipeline"
v-gl-tooltip
@@ -133,9 +139,16 @@ export default {
"
class="js-pipeline-url-detached badge badge-info"
data-testid="pipeline-url-detached"
+ >{{ __('detached') }}</span
+ >
+ <span
+ v-if="isInFork"
+ v-gl-tooltip
+ :title="__('Pipeline ran in fork of project')"
+ class="badge badge-info"
+ data-testid="pipeline-url-fork"
+ >{{ __('fork') }}</span
>
- {{ __('detached') }}
- </span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 9ee427d01fd..ff27226b408 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -62,7 +62,7 @@ export default {
type: String,
required: true,
},
- autoDevopsPath: {
+ autoDevopsHelpPath: {
type: String,
required: true,
},
@@ -342,7 +342,7 @@ export default {
:pipelines="state.pipelines"
:pipeline-schedule-url="pipelineScheduleUrl"
:update-graph-dropdown="updateGraphDropdown"
- :auto-devops-help-path="autoDevopsPath"
+ :auto-devops-help-path="autoDevopsHelpPath"
:view-type="viewType"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
index e52afe08336..1ea71610897 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue
@@ -32,7 +32,7 @@ export default {
if (action.scheduled_at) {
const confirmationMessage = sprintf(
s__(
- "DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after it's timer finishes.",
+ 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.',
),
{ jobName: action.name },
);
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 1d117cfe34a..5548a1021f5 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -53,12 +53,12 @@ export default {
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div>
<div class="table-mobile-content">
<p v-if="hasDuration" class="duration">
- <gl-icon name="timer" class="gl-vertical-align-baseline!" aria-hidden="true" />
+ <gl-icon name="timer" class="gl-vertical-align-baseline!" />
{{ durationFormatted }}
</p>
<p v-if="hasFinishedTime" class="finished-at d-none d-md-block">
- <gl-icon name="calendar" class="gl-vertical-align-baseline!" aria-hidden="true" />
+ <gl-icon name="calendar" class="gl-vertical-align-baseline!" />
<time
v-gl-tooltip
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
index 7afbb59cbd6..4b4fb6082c6 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -1,6 +1,13 @@
<script>
-import { mapGetters } from 'vuex';
-import { GlModalDirective, GlTooltipDirective, GlFriendlyWrap, GlIcon, GlButton } from '@gitlab/ui';
+import { mapState, mapGetters, mapActions } from 'vuex';
+import {
+ GlModalDirective,
+ GlTooltipDirective,
+ GlFriendlyWrap,
+ GlIcon,
+ GlButton,
+ GlPagination,
+} from '@gitlab/ui';
import { __ } from '~/locale';
import TestCaseDetails from './test_case_details.vue';
@@ -10,6 +17,7 @@ export default {
GlIcon,
GlFriendlyWrap,
GlButton,
+ GlPagination,
TestCaseDetails,
},
directives: {
@@ -24,11 +32,15 @@ export default {
},
},
computed: {
- ...mapGetters(['getSuiteTests']),
+ ...mapState(['pageInfo']),
+ ...mapGetters(['getSuiteTests', 'getSuiteTestCount']),
hasSuites() {
return this.getSuiteTests.length > 0;
},
},
+ methods: {
+ ...mapActions(['setPage']),
+ },
wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'],
};
</script>
@@ -129,6 +141,14 @@ export default {
</div>
</div>
</div>
+
+ <gl-pagination
+ v-model="pageInfo.page"
+ class="gl-display-flex gl-justify-content-center"
+ :per-page="pageInfo.perPage"
+ :total-items="getSuiteTestCount"
+ @input="setPage"
+ />
</div>
<div v-else>
diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
new file mode 100644
index 00000000000..aa33f622ce6
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
@@ -0,0 +1,53 @@
+/**
+ * This function takes the stages and add the stage name
+ * at the group level as `category` to have an easier
+ * implementation while constructions nodes with D3
+ * @param {Array} stages
+ * @returns {Array} - Array of stages with stage name at the group level as `category`
+ */
+export const unwrapArrayOfJobs = (stages = []) => {
+ return stages
+ .map(({ name, groups }) => {
+ return groups.map(group => {
+ return { category: name, ...group };
+ });
+ })
+ .flat(2);
+};
+
+const unwrapGroups = stages => {
+ return stages.map(stage => {
+ const {
+ groups: { nodes: groups },
+ } = stage;
+ return { ...stage, groups };
+ });
+};
+
+const unwrapNodesWithName = (jobArray, prop, field = 'name') => {
+ return jobArray.map(job => {
+ return { ...job, [prop]: job[prop].nodes.map(item => item[field]) };
+ });
+};
+
+const unwrapJobWithNeeds = denodedJobArray => {
+ return unwrapNodesWithName(denodedJobArray, 'needs');
+};
+
+const unwrapStagesWithNeeds = denodedStages => {
+ const unwrappedNestedGroups = unwrapGroups(denodedStages);
+
+ const nodes = unwrappedNestedGroups.map(node => {
+ const { groups } = node;
+ const groupsWithJobs = groups.map(group => {
+ const jobs = unwrapJobWithNeeds(group.jobs.nodes);
+ return { ...group, jobs };
+ });
+
+ return { ...node, groups: groupsWithJobs };
+ });
+
+ return nodes;
+};
+
+export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds };
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 607e7a66f44..757d285ef19 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -28,6 +28,8 @@ export const RAW_TEXT_WARNING = s__(
export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure';
export const DRAW_FAILURE = 'draw_failure';
+export const EMPTY_PIPELINE_DATA = 'empty_data';
+export const INVALID_CI_CONFIG = 'invalid_ci_config';
export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure';
diff --git a/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql b/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql
new file mode 100644
index 00000000000..3bf6d8dc9d8
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql
@@ -0,0 +1,17 @@
+fragment LinkedPipelineData on Pipeline {
+ id
+ iid
+ path
+ status: detailedStatus {
+ group
+ label
+ icon
+ }
+ sourceJob {
+ name
+ }
+ project {
+ name
+ fullPath
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql
new file mode 100644
index 00000000000..25aede49631
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql
@@ -0,0 +1,65 @@
+#import "../fragments/linked_pipelines.fragment.graphql"
+
+query getPipelineDetails($projectPath: ID!, $iid: ID!) {
+ project(fullPath: $projectPath) {
+ pipeline(iid: $iid) {
+ id
+ iid
+ downstream {
+ nodes {
+ ...LinkedPipelineData
+ }
+ }
+ upstream {
+ ...LinkedPipelineData
+ }
+ stages {
+ nodes {
+ name
+ status: detailedStatus {
+ action {
+ icon
+ path
+ title
+ }
+ }
+ groups {
+ nodes {
+ status: detailedStatus {
+ label
+ group
+ icon
+ }
+ name
+ size
+ jobs {
+ nodes {
+ name
+ scheduledAt
+ needs {
+ nodes {
+ name
+ }
+ }
+ status: detailedStatus {
+ icon
+ tooltip
+ hasDetails
+ detailsPath
+ group
+ action {
+ buttonTitle
+ icon
+ path
+ title
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
index 06083daeca0..1b3f80b1f18 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_header_data.query.graphql
@@ -2,6 +2,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) {
pipeline(iid: $iid) {
id
+ iid
status
retryable
cancelable
diff --git a/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql b/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql
new file mode 100644
index 00000000000..1da4fa0a72b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql
@@ -0,0 +1,20 @@
+fragment PipelineStagesConnection on CiConfigStageConnection {
+ nodes {
+ name
+ groups {
+ nodes {
+ name
+ jobs {
+ nodes {
+ name
+ needs {
+ nodes {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js
deleted file mode 100644
index 2dbaa5a5c9a..00000000000
--- a/app/assets/javascripts/pipelines/mixins/graph_width_mixin.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
-import { LAYOUT_CHANGE_DELAY } from '~/pipelines/constants';
-
-export default {
- debouncedResize: null,
- sidebarMutationObserver: null,
- data() {
- return {
- graphLeftPadding: 0,
- graphRightPadding: 0,
- };
- },
- beforeDestroy() {
- window.removeEventListener('resize', this.$options.debouncedResize);
-
- if (this.$options.sidebarMutationObserver) {
- this.$options.sidebarMutationObserver.disconnect();
- }
- },
- created() {
- this.$options.debouncedResize = debounceByAnimationFrame(this.setGraphPadding);
- window.addEventListener('resize', this.$options.debouncedResize);
- },
- mounted() {
- this.setGraphPadding();
-
- this.$options.sidebarMutationObserver = new MutationObserver(this.handleLayoutChange);
- this.$options.sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
- attributes: true,
- childList: false,
- subtree: false,
- });
- },
- methods: {
- setGraphPadding() {
- // only add padding to main graph (not inline upstream/downstream graphs)
- if (this.type && this.type !== 'main') return;
-
- const container = document.querySelector('.js-pipeline-container');
- if (!container) return;
-
- this.graphLeftPadding = container.offsetLeft;
- this.graphRightPadding = window.innerWidth - container.offsetLeft - container.offsetWidth;
- },
- handleLayoutChange() {
- // wait until animations finish, then recalculate padding
- window.setTimeout(this.setGraphPadding, LAYOUT_CHANGE_DELAY);
- },
- },
-};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 29dec2309a7..27f71d2b878 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -3,7 +3,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
-import pipelineGraph from './components/graph/graph_component.vue';
+import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue';
import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import legacyPipelineHeader from './components/legacy_header_component.vue';
@@ -28,7 +28,7 @@ const createLegacyPipelinesDetailApp = mediator => {
new Vue({
el: SELECTORS.PIPELINE_GRAPH,
components: {
- pipelineGraph,
+ PipelineGraphLegacy,
},
mixins: [GraphBundleMixin],
data() {
@@ -37,7 +37,7 @@ const createLegacyPipelinesDetailApp = mediator => {
};
},
render(createElement) {
- return createElement('pipeline-graph', {
+ return createElement('pipeline-graph-legacy', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
@@ -149,7 +149,9 @@ export default async function() {
const { createPipelinesDetailApp } = await import(
/* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph'
);
- createPipelinesDetailApp();
+
+ const { pipelineProjectPath, pipelineIid } = dataset;
+ createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid);
} catch {
Flash(__('An error occurred while loading the pipeline.'));
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js
index 880855cf21d..1b296c305cb 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_graph.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js
@@ -1,7 +1,37 @@
-const createPipelinesDetailApp = () => {
- // Placeholder. See: https://gitlab.com/gitlab-org/gitlab/-/issues/223262
- // eslint-disable-next-line no-useless-return
- return;
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue';
+import { GRAPHQL } from './components/graph/constants';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ batchMax: 2,
+ },
+ ),
+});
+
+const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: selector,
+ components: {
+ PipelineGraphWrapper,
+ },
+ apolloProvider,
+ provide: {
+ pipelineProjectPath,
+ pipelineIid,
+ dataMethod: GRAPHQL,
+ },
+ render(createElement) {
+ return createElement(PipelineGraphWrapper);
+ },
+ });
};
export { createPipelinesDetailApp };
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
new file mode 100644
index 00000000000..4575a99f60f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -0,0 +1,75 @@
+import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { doesHashExistInUrl } from '~/lib/utils/url_utility';
+import {
+ parseBoolean,
+ historyReplaceState,
+ buildUrlWithCurrentLocation,
+} from '~/lib/utils/common_utils';
+import Translate from '~/vue_shared/translate';
+import Pipelines from './components/pipelines_list/pipelines.vue';
+import PipelinesStore from './stores/pipelines_store';
+
+Vue.use(Translate);
+Vue.use(GlToast);
+
+export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
+ const el = document.querySelector(selector);
+ if (!el) {
+ return null;
+ }
+
+ const {
+ endpoint,
+ pipelineScheduleUrl,
+ helpPagePath,
+ emptyStateSvgPath,
+ errorStateSvgPath,
+ noPipelinesSvgPath,
+ autoDevopsHelpPath,
+ newPipelinePath,
+ canCreatePipeline,
+ hasGitlabCi,
+ ciLintPath,
+ resetCachePath,
+ projectId,
+ params,
+ } = el.dataset;
+
+ return new Vue({
+ el,
+ data() {
+ return {
+ store: new PipelinesStore(),
+ };
+ },
+ created() {
+ if (doesHashExistInUrl('delete_success')) {
+ this.$toast.show(__('The pipeline has been deleted'));
+ historyReplaceState(buildUrlWithCurrentLocation());
+ }
+ },
+ render(createElement) {
+ return createElement(Pipelines, {
+ props: {
+ store: this.store,
+ endpoint,
+ pipelineScheduleUrl,
+ helpPagePath,
+ emptyStateSvgPath,
+ errorStateSvgPath,
+ noPipelinesSvgPath,
+ autoDevopsHelpPath,
+ newPipelinePath,
+ canCreatePipeline: parseBoolean(canCreatePipeline),
+ hasGitlabCi: parseBoolean(hasGitlabCi),
+ ciLintPath,
+ resetCachePath,
+ projectId,
+ params: JSON.parse(params),
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
index f10bbeec77c..3c664457756 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
@@ -47,6 +47,7 @@ export const fetchTestSuite = ({ state, commit, dispatch }, index) => {
});
};
+export const setPage = ({ commit }, page) => commit(types.SET_PAGE, page);
export const setSelectedSuiteIndex = ({ commit }, data) =>
commit(types.SET_SELECTED_SUITE_INDEX, data);
export const removeSelectedSuiteIndex = ({ commit }) =>
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js
index c123014756d..56f769c00fa 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js
@@ -14,5 +14,10 @@ export const getSelectedSuite = state =>
export const getSuiteTests = state => {
const { test_cases: testCases = [] } = getSelectedSuite(state);
- return testCases.map(addIconStatus);
+ const { page, perPage } = state.pageInfo;
+ const start = (page - 1) * perPage;
+
+ return testCases.map(addIconStatus).slice(start, start + perPage);
};
+
+export const getSuiteTestCount = state => getSelectedSuite(state)?.test_cases?.length || 0;
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js
index 52345888cb0..803f6bf60b1 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js
@@ -1,3 +1,4 @@
+export const SET_PAGE = 'SET_PAGE';
export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX';
export const SET_SUMMARY = 'SET_SUMMARY';
export const SET_SUITE = 'SET_SUITE';
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
index 3652a12a6ba..cf0bf8483dd 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
@@ -1,6 +1,14 @@
import * as types from './mutation_types';
export default {
+ [types.SET_PAGE](state, page) {
+ Object.assign(state, {
+ pageInfo: Object.assign(state.pageInfo, {
+ page,
+ }),
+ });
+ },
+
[types.SET_SUITE](state, { suite = {}, index = null }) {
state.testReports.test_suites[index] = { ...suite, hasFullSuite: true };
},
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js
index af79521d68a..7f5da549a9d 100644
--- a/app/assets/javascripts/pipelines/stores/test_reports/state.js
+++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js
@@ -4,4 +4,8 @@ export default ({ summaryEndpoint = '', suiteEndpoint = '' }) => ({
testReports: {},
selectedSuiteIndex: null,
isLoading: false,
+ pageInfo: {
+ page: 1,
+ perPage: 20,
+ },
});
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 7d1a1762e0d..28d6c0edb0f 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -5,66 +5,42 @@ export const validateParams = params => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
};
-export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`;
+export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
/**
- * This function takes a json payload that comes from a yml
- * file converted to json through `jsyaml` library. Because we
- * naively convert the entire yaml to json, some keys (like `includes`)
- * are irrelevant to rendering the graph and must be removed. We also
- * restructure the data to have the structure from an API response for the
- * pipeline data.
- * @param {Object} jsonData
- * @returns {Array} - Array of stages containing all jobs
+ * This function takes the stages array and transform it
+ * into a hash where each key is a job name and the job data
+ * is associated to that key.
+ * @param {Array} stages
+ * @returns {Object} - Hash of jobs
*/
-export const preparePipelineGraphData = jsonData => {
- const jsonKeys = Object.keys(jsonData);
- const jobNames = jsonKeys.filter(job => jsonData[job]?.stage);
- // Creates an object with only the valid jobs
- const jobs = jsonKeys.reduce((acc, val) => {
- if (jobNames.includes(val)) {
- return {
- ...acc,
- [val]: { ...jsonData[val], id: createUniqueJobId(jsonData[val].stage, val) },
- };
- }
- return { ...acc };
- }, {});
-
- // We merge both the stages from the "stages" key in the yaml and the stage associated
- // with each job to show the user both the stages they explicitly defined, and those
- // that they added under jobs. We also remove duplicates.
- const jobStages = jobNames.map(job => jsonData[job].stage);
- const userDefinedStages = jsonData?.stages ?? [];
-
- // The order is important here. We always show the stages in order they were
- // defined in the `stages` key first, and then stages that are under the jobs.
- const stages = Array.from(new Set([...userDefinedStages, ...jobStages]));
-
- const arrayOfJobsByStage = stages.map(val => {
- return jobNames.filter(job => {
- return jsonData[job].stage === val;
- });
- });
+export const createJobsHash = (stages = []) => {
+ const jobsHash = {};
- const pipelineData = stages.map((stage, index) => {
- const stageJobs = arrayOfJobsByStage[index];
- return {
- name: stage,
- groups: stageJobs.map(job => {
- return {
- name: job,
- jobs: [{ ...jsonData[job] }],
- id: createUniqueJobId(stage, job),
- };
- }),
- };
+ stages.forEach(stage => {
+ if (stage.groups.length > 0) {
+ stage.groups.forEach(group => {
+ group.jobs.forEach(job => {
+ jobsHash[job.name] = job;
+ });
+ });
+ }
});
- return { stages: pipelineData, jobs };
+ return jobsHash;
};
-export const generateJobNeedsDict = ({ jobs }) => {
+/**
+ * This function takes the jobs hash generated by
+ * `createJobsHash` function and returns an easier
+ * structure to work with for needs relationship
+ * where the key is the job name and the value is an
+ * array of all the needs this job has recursively
+ * (includes the needs of the needs)
+ * @param {Object} jobs
+ * @returns {Object} - Hash of jobs and array of needs
+ */
+export const generateJobNeedsDict = (jobs = {}) => {
const arrOfJobNames = Object.keys(jobs);
return arrOfJobNames.reduce((acc, value) => {
@@ -75,13 +51,12 @@ export const generateJobNeedsDict = ({ jobs }) => {
return jobs[jobName].needs
.map(job => {
- const { id } = jobs[job];
// If we already have the needs of a job in the accumulator,
// then we use the memoized data instead of the recursive call
// to save some performance.
- const newNeeds = acc[id] ?? recursiveNeeds(job);
+ const newNeeds = acc[job] ?? recursiveNeeds(job);
- return [id, ...newNeeds];
+ return [job, ...newNeeds];
})
.flat(Infinity);
};
@@ -91,6 +66,6 @@ export const generateJobNeedsDict = ({ jobs }) => {
// duplicates from the array.
const uniqueValues = Array.from(new Set(recursiveNeeds(value)));
- return { ...acc, [jobs[value].id]: uniqueValues };
+ return { ...acc, [value]: uniqueValues };
}, {});
};
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index 2f35c4485f9..0e12c219e45 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -55,6 +55,7 @@ export default class ProjectFindFile {
}
initEvent() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.inputElement.off('keyup');
this.inputElement.on('keyup', event => {
const target = $(event.target);
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index db2b0856e1b..f7d823802b6 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -4,110 +4,116 @@ import $ from 'jquery';
import Api from './api';
import ProjectSelectComboButton from './project_select_combo_button';
import { s__ } from './locale';
+import { loadCSSFile } from './lib/utils/css_utils';
const projectSelect = () => {
- $('.ajax-project-select').each(function(i, select) {
- let placeholder;
- const simpleFilter = $(select).data('simpleFilter') || false;
- const isInstantiated = $(select).data('select2');
- this.groupId = $(select).data('groupId');
- this.userId = $(select).data('userId');
- this.includeGroups = $(select).data('includeGroups');
- this.allProjects = $(select).data('allProjects') || false;
- this.orderBy = $(select).data('orderBy') || 'id';
- this.withIssuesEnabled = $(select).data('withIssuesEnabled');
- this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
- this.withShared =
- $(select).data('withShared') === undefined ? true : $(select).data('withShared');
- this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
- this.allowClear = $(select).data('allowClear') || false;
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ $('.ajax-project-select').each(function(i, select) {
+ let placeholder;
+ const simpleFilter = $(select).data('simpleFilter') || false;
+ const isInstantiated = $(select).data('select2');
+ this.groupId = $(select).data('groupId');
+ this.userId = $(select).data('userId');
+ this.includeGroups = $(select).data('includeGroups');
+ this.allProjects = $(select).data('allProjects') || false;
+ this.orderBy = $(select).data('orderBy') || 'id';
+ this.withIssuesEnabled = $(select).data('withIssuesEnabled');
+ this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
+ this.withShared =
+ $(select).data('withShared') === undefined ? true : $(select).data('withShared');
+ this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
+ this.allowClear = $(select).data('allowClear') || false;
- placeholder = s__('ProjectSelect|Search for project');
- if (this.includeGroups) {
- placeholder += s__('ProjectSelect| or group');
- }
-
- $(select).select2({
- placeholder,
- minimumInputLength: 0,
- query: query => {
- let projectsCallback;
- const finalCallback = function(projects) {
- const data = {
- results: projects,
- };
- return query.callback(data);
- };
+ placeholder = s__('ProjectSelect|Search for project');
if (this.includeGroups) {
- projectsCallback = function(projects) {
- const groupsCallback = function(groups) {
- const data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(query.term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (this.groupId) {
- return Api.groupProjects(
- this.groupId,
- query.term,
- {
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- with_shared: this.withShared,
- include_subgroups: this.includeProjectsInSubgroups,
- order_by: 'similarity',
- },
- projectsCallback,
- );
- } else if (this.userId) {
- return Api.userProjects(
- this.userId,
- query.term,
- {
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- with_shared: this.withShared,
- include_subgroups: this.includeProjectsInSubgroups,
- },
- projectsCallback,
- );
+ placeholder += s__('ProjectSelect| or group');
}
- return Api.projects(
- query.term,
- {
- order_by: this.orderBy,
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- membership: !this.allProjects,
+
+ $(select).select2({
+ placeholder,
+ minimumInputLength: 0,
+ query: query => {
+ let projectsCallback;
+ const finalCallback = function(projects) {
+ const data = {
+ results: projects,
+ };
+ return query.callback(data);
+ };
+ if (this.includeGroups) {
+ projectsCallback = function(projects) {
+ const groupsCallback = function(groups) {
+ const data = groups.concat(projects);
+ return finalCallback(data);
+ };
+ return Api.groups(query.term, {}, groupsCallback);
+ };
+ } else {
+ projectsCallback = finalCallback;
+ }
+ if (this.groupId) {
+ return Api.groupProjects(
+ this.groupId,
+ query.term,
+ {
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ with_shared: this.withShared,
+ include_subgroups: this.includeProjectsInSubgroups,
+ order_by: 'similarity',
+ },
+ projectsCallback,
+ );
+ } else if (this.userId) {
+ return Api.userProjects(
+ this.userId,
+ query.term,
+ {
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ with_shared: this.withShared,
+ include_subgroups: this.includeProjectsInSubgroups,
+ },
+ projectsCallback,
+ );
+ }
+ return Api.projects(
+ query.term,
+ {
+ order_by: this.orderBy,
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ membership: !this.allProjects,
+ },
+ projectsCallback,
+ );
+ },
+ id(project) {
+ if (simpleFilter) return project.id;
+ return JSON.stringify({
+ name: project.name,
+ url: project.web_url,
+ });
+ },
+ text(project) {
+ return project.name_with_namespace || project.name;
},
- projectsCallback,
- );
- },
- id(project) {
- if (simpleFilter) return project.id;
- return JSON.stringify({
- name: project.name,
- url: project.web_url,
- });
- },
- text(project) {
- return project.name_with_namespace || project.name;
- },
- initSelection(el, callback) {
- return Api.project(el.val()).then(({ data }) => callback(data));
- },
+ initSelection(el, callback) {
+ // eslint-disable-next-line promise/no-nesting
+ return Api.project(el.val()).then(({ data }) => callback(data));
+ },
- allowClear: this.allowClear,
+ allowClear: this.allowClear,
- dropdownCssClass: 'ajax-project-dropdown',
- });
- if (isInstantiated || simpleFilter) return select;
- return new ProjectSelectComboButton(select);
- });
+ dropdownCssClass: 'ajax-project-dropdown',
+ });
+ if (isInstantiated || simpleFilter) return select;
+ return new ProjectSelectComboButton(select);
+ });
+ })
+ .catch(() => {});
};
export default () => {
diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js
index d3b5f532dc1..865dd23bd80 100644
--- a/app/assets/javascripts/project_select_combo_button.js
+++ b/app/assets/javascripts/project_select_combo_button.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import AccessorUtilities from './lib/utils/accessor';
+import { loadCSSFile } from './lib/utils/css_utils';
export default class ProjectSelectComboButton {
constructor(select) {
@@ -46,9 +47,14 @@ export default class ProjectSelectComboButton {
openDropdown(event) {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
- $(event.currentTarget)
- .siblings('.project-item-select')
- .select2('open');
+ // eslint-disable-next-line promise/no-nesting
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ $(event.currentTarget)
+ .siblings('.project-item-select')
+ .select2('open');
+ })
+ .catch(() => {});
})
.catch(() => {});
}
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index a6019e9c01b..bc3b29cde0a 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -1,6 +1,10 @@
import { s__ } from '~/locale';
export default {
+ sample: {
+ text: s__('ProjectTemplates|Sample GitLab Project'),
+ icon: '.template-option .icon-sample',
+ },
rails: {
text: s__('ProjectTemplates|Ruby on Rails'),
icon: '.template-option .icon-rails',
diff --git a/app/assets/javascripts/projects/default_sample_data_templates.js b/app/assets/javascripts/projects/default_sample_data_templates.js
deleted file mode 100644
index 7c45e7ac62f..00000000000
--- a/app/assets/javascripts/projects/default_sample_data_templates.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { s__ } from '~/locale';
-
-export default {
- basic: {
- text: s__('ProjectTemplates|Basic'),
- icon: '.template-option .icon-basic',
- },
- serenity_valley: {
- text: s__('ProjectTemplates|Serenity Valley'),
- icon: '.template-option .icon-serenity_valley',
- },
-};
diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue
index f404e6030f4..2e16071e563 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
@@ -12,6 +12,7 @@ import ciCdProjectIllustration from '../illustrations/ci-cd-project.svg';
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 = [
{
name: BLANK_PANEL,
@@ -105,7 +106,7 @@ export default {
this.handleLocationHashChange();
if (this.hasErrors) {
- this.activeTab = BLANK_PANEL;
+ this.activeTab = localStorage.getItem(LAST_ACTIVE_TAB_KEY) || BLANK_PANEL;
}
window.addEventListener('hashchange', () => {
@@ -127,6 +128,9 @@ export default {
handleLocationHashChange() {
this.activeTab = window.location.hash.substring(1) || null;
+ if (this.activeTab) {
+ localStorage.setItem(LAST_ACTIVE_TAB_KEY, this.activeTab);
+ }
},
},
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index c6e2b2e1140..4bf837faed1 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -1,66 +1,203 @@
<script>
import dateFormat from 'dateformat';
import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import { __, sprintf } from '~/locale';
+import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
import { getDateInPast } from '~/lib/utils/datetime_utility';
+import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql';
+import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql';
import StatisticsList from './statistics_list.vue';
import PipelinesAreaChart from './pipelines_area_chart.vue';
import {
CHART_CONTAINER_HEIGHT,
- INNER_CHART_HEIGHT,
- X_AXIS_LABEL_ROTATION,
- X_AXIS_TITLE_OFFSET,
CHART_DATE_FORMAT,
+ DEFAULT,
+ INNER_CHART_HEIGHT,
+ LOAD_ANALYTICS_FAILURE,
+ LOAD_PIPELINES_FAILURE,
ONE_WEEK_AGO_DAYS,
ONE_MONTH_AGO_DAYS,
+ PARSE_FAILURE,
+ UNSUPPORTED_DATA,
+ X_AXIS_LABEL_ROTATION,
+ X_AXIS_TITLE_OFFSET,
} from '../constants';
+const defaultCountValues = {
+ totalPipelines: {
+ count: 0,
+ },
+ successfulPipelines: {
+ count: 0,
+ },
+};
+
+const defaultAnalyticsValues = {
+ weekPipelinesTotals: [],
+ weekPipelinesLabels: [],
+ weekPipelinesSuccessful: [],
+ monthPipelinesLabels: [],
+ monthPipelinesTotals: [],
+ monthPipelinesSuccessful: [],
+ yearPipelinesLabels: [],
+ yearPipelinesTotals: [],
+ yearPipelinesSuccessful: [],
+ pipelineTimesLabels: [],
+ pipelineTimesValues: [],
+};
+
export default {
components: {
- StatisticsList,
+ GlAlert,
GlColumnChart,
+ GlSkeletonLoader,
+ StatisticsList,
PipelinesAreaChart,
},
- props: {
- counts: {
- type: Object,
- required: true,
- },
- timesChartData: {
- type: Object,
- required: true,
- },
- lastWeekChartData: {
- type: Object,
- required: true,
- },
- lastMonthChartData: {
- type: Object,
- required: true,
- },
- lastYearChartData: {
- type: Object,
- required: true,
+ inject: {
+ projectPath: {
+ type: String,
+ default: '',
},
},
data() {
return {
- timesChartTransformedData: [
- {
- name: 'full',
- data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
- },
- ],
+ counts: {
+ ...defaultCountValues,
+ },
+ analytics: {
+ ...defaultAnalyticsValues,
+ },
+ showFailureAlert: false,
+ failureType: null,
};
},
+ apollo: {
+ counts: {
+ query: getPipelineCountByStatus,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data?.project;
+ },
+ error() {
+ this.reportFailure(LOAD_PIPELINES_FAILURE);
+ },
+ },
+ analytics: {
+ query: getProjectPipelineStatistics,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return data?.project?.pipelineAnalytics;
+ },
+ error() {
+ this.reportFailure(LOAD_ANALYTICS_FAILURE);
+ },
+ },
+ },
computed: {
+ failure() {
+ switch (this.failureType) {
+ case LOAD_ANALYTICS_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_ANALYTICS_FAILURE],
+ variant: 'danger',
+ };
+ case PARSE_FAILURE:
+ return {
+ text: this.$options.errorTexts[PARSE_FAILURE],
+ variant: 'danger',
+ };
+ case UNSUPPORTED_DATA:
+ return {
+ text: this.$options.errorTexts[UNSUPPORTED_DATA],
+ variant: 'info',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ variant: 'danger',
+ };
+ }
+ },
+ successRatio() {
+ const { successfulPipelines, failedPipelines } = this.counts;
+ const successfulCount = successfulPipelines?.count;
+ const failedCount = failedPipelines?.count;
+ const ratio = (successfulCount / (successfulCount + failedCount)) * 100;
+
+ return failedCount === 0 ? 100 : ratio;
+ },
+ formattedCounts() {
+ const {
+ totalPipelines,
+ successfulPipelines,
+ failedPipelines,
+ totalPipelineDuration,
+ } = this.counts;
+
+ return {
+ total: totalPipelines?.count,
+ success: successfulPipelines?.count,
+ failed: failedPipelines?.count,
+ successRatio: this.successRatio,
+ totalDuration: totalPipelineDuration,
+ };
+ },
areaCharts() {
const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles;
+ let areaChartsData = [];
+ try {
+ areaChartsData = [
+ this.buildAreaChartData(lastWeek, this.lastWeekChartData),
+ this.buildAreaChartData(lastMonth, this.lastMonthChartData),
+ this.buildAreaChartData(lastYear, this.lastYearChartData),
+ ];
+ } catch {
+ areaChartsData = [];
+ this.reportFailure(PARSE_FAILURE);
+ }
+
+ return areaChartsData;
+ },
+ lastWeekChartData() {
+ return {
+ labels: this.analytics.weekPipelinesLabels,
+ totals: this.analytics.weekPipelinesTotals,
+ success: this.analytics.weekPipelinesSuccessful,
+ };
+ },
+ lastMonthChartData() {
+ return {
+ labels: this.analytics.monthPipelinesLabels,
+ totals: this.analytics.monthPipelinesTotals,
+ success: this.analytics.monthPipelinesSuccessful,
+ };
+ },
+ lastYearChartData() {
+ return {
+ labels: this.analytics.yearPipelinesLabels,
+ totals: this.analytics.yearPipelinesTotals,
+ success: this.analytics.yearPipelinesSuccessful,
+ };
+ },
+ timesChartTransformedData() {
return [
- this.buildAreaChartData(lastWeek, this.lastWeekChartData),
- this.buildAreaChartData(lastMonth, this.lastMonthChartData),
- this.buildAreaChartData(lastYear, this.lastYearChartData),
+ {
+ name: 'full',
+ data: this.mergeLabelsAndValues(
+ this.analytics.pipelineTimesLabels,
+ this.analytics.pipelineTimesValues,
+ ),
+ },
];
},
},
@@ -85,6 +222,13 @@ export default {
],
};
},
+ hideAlert() {
+ this.showFailureAlert = false;
+ },
+ reportFailure(type) {
+ this.showFailureAlert = true;
+ this.failureType = type;
+ },
},
chartContainerHeight: CHART_CONTAINER_HEIGHT,
timesChartOptions: {
@@ -96,6 +240,16 @@ export default {
nameGap: X_AXIS_TITLE_OFFSET,
},
},
+ errorTexts: {
+ [LOAD_ANALYTICS_FAILURE]: s__(
+ 'PipelineCharts|An error has ocurred when retrieving the analytics data',
+ ),
+ [LOAD_PIPELINES_FAILURE]: s__(
+ 'PipelineCharts|An error has ocurred when retrieving the pipelines data',
+ ),
+ [PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'),
+ [DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'),
+ },
get chartTitles() {
const today = dateFormat(new Date(), CHART_DATE_FORMAT);
const pastDate = timeScale =>
@@ -116,13 +270,17 @@ export default {
</script>
<template>
<div>
- <div class="mb-3">
+ <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">
+ {{ failure.text }}
+ </gl-alert>
+ <div class="gl-mb-3">
<h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
</div>
- <h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
+ <h4 class="gl-my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
<div class="row">
<div class="col-md-6">
- <statistics-list :counts="counts" />
+ <gl-skeleton-loader v-if="$apollo.queries.counts.loading" :lines="5" />
+ <statistics-list v-else :counts="formattedCounts" />
</div>
<div class="col-md-6">
<strong>
@@ -139,7 +297,7 @@ export default {
</div>
</div>
<hr />
- <h4 class="my-4">{{ __('Pipelines charts') }}</h4>
+ <h4 class="gl-my-4">{{ __('Pipelines charts') }}</h4>
<pipelines-area-chart
v-for="(chart, index) in areaCharts"
:key="index"
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue b/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue
new file mode 100644
index 00000000000..c6e2b2e1140
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue
@@ -0,0 +1,151 @@
+<script>
+import dateFormat from 'dateformat';
+import { GlColumnChart } from '@gitlab/ui/dist/charts';
+import { __, sprintf } from '~/locale';
+import { getDateInPast } from '~/lib/utils/datetime_utility';
+import StatisticsList from './statistics_list.vue';
+import PipelinesAreaChart from './pipelines_area_chart.vue';
+import {
+ CHART_CONTAINER_HEIGHT,
+ INNER_CHART_HEIGHT,
+ X_AXIS_LABEL_ROTATION,
+ X_AXIS_TITLE_OFFSET,
+ CHART_DATE_FORMAT,
+ ONE_WEEK_AGO_DAYS,
+ ONE_MONTH_AGO_DAYS,
+} from '../constants';
+
+export default {
+ components: {
+ StatisticsList,
+ GlColumnChart,
+ PipelinesAreaChart,
+ },
+ props: {
+ counts: {
+ type: Object,
+ required: true,
+ },
+ timesChartData: {
+ type: Object,
+ required: true,
+ },
+ lastWeekChartData: {
+ type: Object,
+ required: true,
+ },
+ lastMonthChartData: {
+ type: Object,
+ required: true,
+ },
+ lastYearChartData: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ timesChartTransformedData: [
+ {
+ name: 'full',
+ data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
+ },
+ ],
+ };
+ },
+ computed: {
+ areaCharts() {
+ const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles;
+
+ return [
+ this.buildAreaChartData(lastWeek, this.lastWeekChartData),
+ this.buildAreaChartData(lastMonth, this.lastMonthChartData),
+ this.buildAreaChartData(lastYear, this.lastYearChartData),
+ ];
+ },
+ },
+ methods: {
+ mergeLabelsAndValues(labels, values) {
+ return labels.map((label, index) => [label, values[index]]);
+ },
+ buildAreaChartData(title, data) {
+ const { labels, totals, success } = data;
+
+ return {
+ title,
+ data: [
+ {
+ name: 'all',
+ data: this.mergeLabelsAndValues(labels, totals),
+ },
+ {
+ name: 'success',
+ data: this.mergeLabelsAndValues(labels, success),
+ },
+ ],
+ };
+ },
+ },
+ chartContainerHeight: CHART_CONTAINER_HEIGHT,
+ timesChartOptions: {
+ height: INNER_CHART_HEIGHT,
+ xAxis: {
+ axisLabel: {
+ rotate: X_AXIS_LABEL_ROTATION,
+ },
+ nameGap: X_AXIS_TITLE_OFFSET,
+ },
+ },
+ get chartTitles() {
+ const today = dateFormat(new Date(), CHART_DATE_FORMAT);
+ const pastDate = timeScale =>
+ dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT);
+ return {
+ lastWeek: sprintf(__('Pipelines for last week (%{oneWeekAgo} - %{today})'), {
+ oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS),
+ today,
+ }),
+ lastMonth: sprintf(__('Pipelines for last month (%{oneMonthAgo} - %{today})'), {
+ oneMonthAgo: pastDate(ONE_MONTH_AGO_DAYS),
+ today,
+ }),
+ lastYear: __('Pipelines for last year'),
+ };
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="mb-3">
+ <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
+ </div>
+ <h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
+ <div class="row">
+ <div class="col-md-6">
+ <statistics-list :counts="counts" />
+ </div>
+ <div class="col-md-6">
+ <strong>
+ {{ __('Duration for the last 30 commits') }}
+ </strong>
+ <gl-column-chart
+ :height="$options.chartContainerHeight"
+ :option="$options.timesChartOptions"
+ :bars="timesChartTransformedData"
+ :y-axis-title="__('Minutes')"
+ :x-axis-title="__('Commit')"
+ x-axis-type="category"
+ />
+ </div>
+ </div>
+ <hr />
+ <h4 class="my-4">{{ __('Pipelines charts') }}</h4>
+ <pipelines-area-chart
+ v-for="(chart, index) in areaCharts"
+ :key="index"
+ :chart-data="chart.data"
+ >
+ {{ chart.title }}
+ </pipelines-area-chart>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
index aa59717ddcd..94cecd2e479 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue
@@ -1,7 +1,10 @@
<script>
import { formatTime } from '~/lib/utils/datetime_utility';
+import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
import { s__, n__ } from '~/locale';
+const defaultPrecision = 2;
+
export default {
props: {
counts: {
@@ -14,6 +17,8 @@ export default {
return formatTime(this.counts.totalDuration);
},
statistics() {
+ const formatter = getFormatter(SUPPORTED_FORMATS.percentHundred);
+
return [
{
title: s__('PipelineCharts|Total:'),
@@ -29,7 +34,7 @@ export default {
},
{
title: s__('PipelineCharts|Success ratio:'),
- value: `${this.counts.successRatio}%`,
+ value: formatter(this.counts.successRatio, defaultPrecision),
},
{
title: s__('PipelineCharts|Total duration:'),
diff --git a/app/assets/javascripts/projects/pipelines/charts/constants.js b/app/assets/javascripts/projects/pipelines/charts/constants.js
index 5dbe3c01100..079e23943c1 100644
--- a/app/assets/javascripts/projects/pipelines/charts/constants.js
+++ b/app/assets/javascripts/projects/pipelines/charts/constants.js
@@ -11,3 +11,9 @@ export const ONE_WEEK_AGO_DAYS = 7;
export const ONE_MONTH_AGO_DAYS = 31;
export const CHART_DATE_FORMAT = 'dd mmm';
+
+export const DEFAULT = 'default';
+export const PARSE_FAILURE = 'parse_failure';
+export const LOAD_ANALYTICS_FAILURE = 'load_analytics_failure';
+export const LOAD_PIPELINES_FAILURE = 'load_analytics_failure';
+export const UNSUPPORTED_DATA = 'unsupported_data';
diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql
new file mode 100644
index 00000000000..eb0dbf8dd16
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql
@@ -0,0 +1,14 @@
+query getPipelineCountByStatus($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ totalPipelines: pipelines {
+ count
+ }
+ successfulPipelines: pipelines(status: SUCCESS) {
+ count
+ }
+ failedPipelines: pipelines(status: FAILED) {
+ count
+ }
+ totalPipelineDuration
+ }
+}
diff --git a/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql
new file mode 100644
index 00000000000..18b645f8831
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql
@@ -0,0 +1,17 @@
+query getProjectPipelineStatistics($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ pipelineAnalytics {
+ weekPipelinesTotals
+ weekPipelinesLabels
+ weekPipelinesSuccessful
+ monthPipelinesLabels
+ monthPipelinesTotals
+ monthPipelinesSuccessful
+ yearPipelinesLabels
+ yearPipelinesTotals
+ yearPipelinesSuccessful
+ pipelineTimesLabels
+ pipelineTimesValues
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index eef1bc2d28b..f6e79f0ab51 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -1,8 +1,20 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import ProjectPipelinesChartsLegacy from './components/app_legacy.vue';
import ProjectPipelinesCharts from './components/app.vue';
-export default () => {
- const el = document.querySelector('#js-project-pipelines-charts-app');
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+const mountPipelineChartsApp = el => {
+ // Not all of the values will be defined since some them will be
+ // empty depending on the value of the graphql_pipeline_analytics
+ // feature flag, once the rollout of the feature flag is completed
+ // the undefined values will be deleted
const {
countsFailed,
countsSuccess,
@@ -20,22 +32,48 @@ export default () => {
lastYearChartLabels,
lastYearChartTotals,
lastYearChartSuccess,
+ projectPath,
} = el.dataset;
- const parseAreaChartData = (labels, totals, success) => ({
- labels: JSON.parse(labels),
- totals: JSON.parse(totals),
- success: JSON.parse(success),
- });
+ const parseAreaChartData = (labels, totals, success) => {
+ let parsedData = {};
+
+ try {
+ parsedData = {
+ labels: JSON.parse(labels),
+ totals: JSON.parse(totals),
+ success: JSON.parse(success),
+ };
+ } catch {
+ parsedData = {};
+ }
+
+ return parsedData;
+ };
+
+ if (gon?.features?.graphqlPipelineAnalytics) {
+ return new Vue({
+ el,
+ name: 'ProjectPipelinesChartsApp',
+ components: {
+ ProjectPipelinesCharts,
+ },
+ apolloProvider,
+ provide: {
+ projectPath,
+ },
+ render: createElement => createElement(ProjectPipelinesCharts, {}),
+ });
+ }
return new Vue({
el,
- name: 'ProjectPipelinesChartsApp',
+ name: 'ProjectPipelinesChartsAppLegacy',
components: {
- ProjectPipelinesCharts,
+ ProjectPipelinesChartsLegacy,
},
render: createElement =>
- createElement(ProjectPipelinesCharts, {
+ createElement(ProjectPipelinesChartsLegacy, {
props: {
counts: {
failed: countsFailed,
@@ -67,3 +105,8 @@ export default () => {
}),
});
};
+
+export default () => {
+ const el = document.querySelector('#js-project-pipelines-charts-app');
+ return !el ? {} : mountPipelineChartsApp(el);
+};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index d74a2d06786..d54a48cc444 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
-import DEFAULT_SAMPLE_DATA_TEMPLATES from '~/projects/default_sample_data_templates';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
import {
convertToTitleCase,
@@ -26,12 +25,14 @@ const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingPr
};
const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$projectNameInput.off('keyup change').on('keyup change', () => {
onProjectNameChange($projectNameInput, $projectPathInput);
hasUserDefinedProjectName = $projectNameInput.val().trim().length > 0;
hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
});
+ // eslint-disable-next-line @gitlab/no-global-event-off
$projectPathInput.off('keyup change').on('keyup change', () => {
onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName);
hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
@@ -137,6 +138,7 @@ const bindEvents = () => {
target.focus();
})
.on('hide.bs.popover', () => {
+ // eslint-disable-next-line @gitlab/no-global-event-off
$(document).off('click.popover touchstart.popover');
});
}
@@ -147,8 +149,7 @@ const bindEvents = () => {
$selectedIcon.empty();
const value = $(this).val();
- const selectedTemplate =
- DEFAULT_PROJECT_TEMPLATES[value] || DEFAULT_SAMPLE_DATA_TEMPLATES[value];
+ const selectedTemplate = DEFAULT_PROJECT_TEMPLATES[value];
$selectedTemplateText.text(selectedTemplate.text);
$(selectedTemplate.icon)
.clone()
diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js
index 3ca5bca4bf2..cb4fd5265da 100644
--- a/app/assets/javascripts/projects/settings/access_dropdown.js
+++ b/app/assets/javascripts/projects/settings/access_dropdown.js
@@ -48,11 +48,12 @@ export default class AccessDropdown {
clicked: options => {
const { $el, e } = options;
const item = options.selectedObj;
+ const fossWithMergeAccess = !this.hasLicense && this.accessLevel === ACCESS_LEVELS.MERGE;
e.preventDefault();
- if (!this.hasLicense) {
- // We're not multiselecting quite yet with FOSS:
+ if (fossWithMergeAccess) {
+ // We're not multiselecting quite yet in "Merge" access dropdown, on FOSS:
// remove all preselected items before selecting this item
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37499
this.accessLevelsData.forEach(level => {
@@ -62,7 +63,7 @@ export default class AccessDropdown {
if ($el.is('.is-active')) {
if (this.noOneObj) {
- if (item.id === this.noOneObj.id && this.hasLicense) {
+ if (item.id === this.noOneObj.id && !fossWithMergeAccess) {
// remove all others selected items
this.accessLevelsData.forEach(level => {
if (level.id !== item.id) {
diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
new file mode 100644
index 00000000000..a4924033c1e
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
@@ -0,0 +1,79 @@
+<script>
+import { GlAlert, GlToggle, GlTooltip } from '@gitlab/ui';
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+
+const DEFAULT_ERROR_MESSAGE = __('An error occurred while updating the configuration.');
+
+export default {
+ components: {
+ GlAlert,
+ GlToggle,
+ GlTooltip,
+ },
+ props: {
+ isDisabledAndUnoverridable: {
+ type: Boolean,
+ required: true,
+ },
+ isEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ updatePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isLoading: false,
+ isSharedRunnerEnabled: false,
+ errorMessage: null,
+ };
+ },
+ created() {
+ this.isSharedRunnerEnabled = this.isEnabled;
+ },
+ methods: {
+ toggleSharedRunners() {
+ this.isLoading = true;
+ this.errorMessage = null;
+
+ axios
+ .post(this.updatePath)
+ .then(() => {
+ this.isLoading = false;
+ this.isSharedRunnerEnabled = !this.isSharedRunnerEnabled;
+ })
+ .catch(error => {
+ this.isLoading = false;
+ this.errorMessage = error.response?.data?.error || DEFAULT_ERROR_MESSAGE;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <section class="gl-mt-5">
+ <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" :dismissible="false">
+ {{ errorMessage }}
+ </gl-alert>
+ <div ref="sharedRunnersToggle">
+ <gl-toggle
+ :disabled="isDisabledAndUnoverridable"
+ :is-loading="isLoading"
+ :label="__('Enable shared runners for this project')"
+ :value="isSharedRunnerEnabled"
+ data-testid="toggle-shared-runners"
+ @change="toggleSharedRunners"
+ />
+ </div>
+ <gl-tooltip v-if="isDisabledAndUnoverridable" :target="() => $refs.sharedRunnersToggle">
+ {{ __('Shared runners are disabled on group level') }}
+ </gl-tooltip>
+ </section>
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
new file mode 100644
index 00000000000..c5d45fe6fed
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/mount_shared_runners_toggle.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import SharedRunnersToggle from '~/projects/settings/components/shared_runners_toggle.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default (containerId = 'toggle-shared-runners-form') => {
+ const containerEl = document.getElementById(containerId);
+ const { isDisabledAndUnoverridable, isEnabled, updatePath } = containerEl.dataset;
+
+ return new Vue({
+ el: containerEl,
+ render(createElement) {
+ return createElement(SharedRunnersToggle, {
+ props: {
+ isDisabledAndUnoverridable: parseBoolean(isDisabledAndUnoverridable),
+ isEnabled: parseBoolean(isEnabled),
+ updatePath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
index df7d9b56aed..a07c57c42cb 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue
@@ -30,6 +30,10 @@ export default {
required: false,
default: '',
},
+ customEmailEnabled: {
+ type: Boolean,
+ required: false,
+ },
selectedTemplate: {
type: String,
required: false,
@@ -140,6 +144,7 @@ export default {
:is-enabled="isEnabled"
:incoming-email="incomingEmail"
:custom-email="updatedCustomEmail"
+ :custom-email-enabled="customEmailEnabled"
:initial-selected-template="selectedTemplate"
:initial-outgoing-name="outgoingName"
:initial-project-key="projectKey"
diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
index 5d120fd0b3f..2896cb491b5 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -31,6 +31,10 @@ export default {
required: false,
default: '',
},
+ customEmailEnabled: {
+ type: Boolean,
+ required: false,
+ },
initialSelectedTemplate: {
type: String,
required: false,
@@ -69,7 +73,7 @@ export default {
return [''].concat(this.templates);
},
hasProjectKeySupport() {
- return Boolean(this.glFeatures.serviceDeskCustomAddress);
+ return Boolean(this.customEmailEnabled);
},
email() {
return this.customEmail || this.incomingEmail;
diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js
index c73163788ef..8f9828dd73d 100644
--- a/app/assets/javascripts/projects/settings_service_desk/index.js
+++ b/app/assets/javascripts/projects/settings_service_desk/index.js
@@ -18,6 +18,7 @@ export default () => {
endpoint: dataset.endpoint,
incomingEmail: dataset.incomingEmail,
customEmail: dataset.customEmail,
+ customEmailEnabled: parseBoolean(dataset.customEmailEnabled),
selectedTemplate: dataset.selectedTemplate,
outgoingName: dataset.outgoingName,
projectKey: dataset.projectKey,
@@ -31,6 +32,7 @@ export default () => {
endpoint: this.endpoint,
incomingEmail: this.incomingEmail,
customEmail: this.customEmail,
+ customEmailEnabled: this.customEmailEnabled,
selectedTemplate: this.selectedTemplate,
outgoingName: this.outgoingName,
projectKey: this.projectKey,
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
index ff613daf7fa..3eeb7b29386 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue
@@ -1,15 +1,29 @@
<script>
import { GlSprintf } from '@gitlab/ui';
+import { sprintf } from '~/locale';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
-import { DETAILS_PAGE_TITLE } from '../../constants/index';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { DETAILS_PAGE_TITLE, UPDATED_AT } from '../../constants/index';
export default {
- components: { GlSprintf, TitleArea },
+ components: { GlSprintf, TitleArea, MetadataItem },
+ mixins: [timeagoMixin],
props: {
- imageName: {
- type: String,
- required: false,
- default: '',
+ image: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ visibilityIcon() {
+ return this.image?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
+ },
+ timeAgo() {
+ return this.timeFormatted(this.image.updatedAt);
+ },
+ updatedText() {
+ return sprintf(UPDATED_AT, { time: this.timeAgo });
},
},
i18n: {
@@ -23,9 +37,17 @@ export default {
<template #title>
<gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
<template #imageName>
- {{ imageName }}
+ {{ image.name }}
</template>
</gl-sprintf>
</template>
+ <template #metadata-updated>
+ <metadata-item
+ :icon="visibilityIcon"
+ :text="updatedText"
+ size="xl"
+ data-testid="updated-and-visibility"
+ />
+ </template>
</title-area>
</template>
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
index 2844b4ffde3..ad39a898e7b 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue
@@ -34,7 +34,7 @@ export default {
return this.tags.some(tag => this.selectedItems[tag.name]);
},
showMultiDeleteButton() {
- return this.tags.some(tag => tag.destroy_path) && !this.isMobile;
+ return this.tags.some(tag => tag.canDelete) && !this.isMobile;
},
},
methods: {
diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
index 2edeac1144f..5aeafd318aa 100644
--- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue
@@ -63,7 +63,7 @@ export default {
},
computed: {
formattedSize() {
- return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : NOT_AVAILABLE_SIZE;
+ return this.tag.totalSize ? numberToHumanSize(this.tag.totalSize) : NOT_AVAILABLE_SIZE;
},
layers() {
return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : '';
@@ -76,10 +76,10 @@ export default {
return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT;
},
publishedDate() {
- return formatDate(this.tag.created_at, 'isoDate');
+ return formatDate(this.tag.createdAt, 'isoDate');
},
publishedTime() {
- return formatDate(this.tag.created_at, 'hh:MM Z');
+ return formatDate(this.tag.createdAt, 'hh:MM Z');
},
formattedRevision() {
// to be removed when API response is adjusted
@@ -101,7 +101,7 @@ export default {
<list-item v-bind="$attrs" :selected="selected">
<template #left-action>
<gl-form-checkbox
- v-if="Boolean(tag.destroy_path)"
+ v-if="tag.canDelete"
:disabled="invalidTag"
class="gl-m-0"
:checked="selected"
@@ -148,7 +148,7 @@ export default {
<span data-testid="time">
<gl-sprintf :message="$options.i18n.CREATED_AT_LABEL">
<template #timeInfo>
- <time-ago-tooltip :time="tag.created_at" />
+ <time-ago-tooltip :time="tag.createdAt" />
</template>
</gl-sprintf>
</span>
@@ -162,10 +162,10 @@ export default {
</template>
<template #right-action>
<delete-button
- :disabled="!tag.destroy_path || invalidTag"
+ :disabled="!tag.canDelete || invalidTag"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
- :tooltip-disabled="Boolean(tag.destroy_path)"
+ :tooltip-disabled="tag.canDelete"
data-testid="single-delete-button"
@delete="$emit('delete')"
/>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
index ba55822f0ca..319666210d6 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue
@@ -1,6 +1,5 @@
<script>
import { GlDropdown } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
import Tracking from '~/tracking';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import {
@@ -20,6 +19,7 @@ export default {
GlDropdown,
CodeInstruction,
},
+ inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
mixins: [Tracking.mixin({ label: trackingLabel })],
trackingLabel,
i18n: {
@@ -31,9 +31,6 @@ export default {
PUSH_COMMAND_LABEL,
COPY_PUSH_TITLE,
},
- computed: {
- ...mapGetters(['dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand']),
- },
};
</script>
<template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
index 80cc392f86a..26e9fee63af 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/group_empty_state.vue
@@ -1,17 +1,14 @@
<script>
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
-import { mapState } from 'vuex';
export default {
name: 'GroupEmptyState',
+ inject: ['config'],
components: {
GlEmptyState,
GlSprintf,
GlLink,
},
- computed: {
- ...mapState(['config']),
- },
};
</script>
<template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
index d1b9894da0e..f8b3233438f 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
@@ -1,11 +1,11 @@
<script>
-import { GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination } from '@gitlab/ui';
import ImageListRow from './image_list_row.vue';
export default {
name: 'ImageList',
components: {
- GlPagination,
+ GlKeysetPagination,
ImageListRow,
},
props: {
@@ -13,19 +13,14 @@ export default {
type: Array,
required: true,
},
- pagination: {
+ pageInfo: {
type: Object,
required: true,
},
},
computed: {
- currentPage: {
- get() {
- return this.pagination.page;
- },
- set(page) {
- this.$emit('pageChange', page);
- },
+ showPagination() {
+ return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
},
},
};
@@ -40,13 +35,15 @@ export default {
:first="index === 0"
@delete="$emit('delete', $event)"
/>
-
- <gl-pagination
- v-model="currentPage"
- :per-page="pagination.perPage"
- :total-items="pagination.total"
- align="center"
- class="w-100 gl-mt-3"
- />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ :has-next-page="pageInfo.hasNextPage"
+ :has-previous-page="pageInfo.hasPreviousPage"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index b0a7c4824bd..3fe61dc231a 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -1,6 +1,8 @@
<script>
import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import DeleteButton from '../delete_button.vue';
@@ -11,6 +13,8 @@ import {
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
+ IMAGE_DELETE_SCHEDULED_STATUS,
+ IMAGE_FAILED_DELETED_STATUS,
} from '../../constants/index';
export default {
@@ -38,19 +42,29 @@ export default {
},
computed: {
disabledDelete() {
- return !this.item.destroy_path || this.item.deleting;
+ return !this.item.canDelete || this.deleting;
+ },
+ id() {
+ return getIdFromGraphQLId(this.item.id);
+ },
+ deleting() {
+ return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS;
+ },
+ failedDelete() {
+ return this.item.status === IMAGE_FAILED_DELETED_STATUS;
},
tagsCountText() {
return n__(
'ContainerRegistry|%{count} Tag',
'ContainerRegistry|%{count} Tags',
- this.item.tags_count,
+ this.item.tagsCount,
);
},
warningIconText() {
- if (this.item.failedDelete) {
+ if (this.failedDelete) {
return ASYNC_DELETE_IMAGE_ERROR_MESSAGE;
- } else if (this.item.cleanup_policy_started_at) {
+ }
+ if (this.item.expirationPolicyStartedAt) {
return CLEANUP_TIMED_OUT_ERROR_MESSAGE;
}
return null;
@@ -63,23 +77,23 @@ export default {
<list-item
v-gl-tooltip="{
placement: 'left',
- disabled: !item.deleting,
+ disabled: !deleting,
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
}"
v-bind="$attrs"
- :disabled="item.deleting"
+ :disabled="deleting"
>
<template #left-primary>
<router-link
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
- :to="{ name: 'details', params: { id: item.id } }"
+ :to="{ name: 'details', params: { id } }"
>
{{ item.path }}
</router-link>
<clipboard-button
v-if="item.location"
- :disabled="item.deleting"
+ :disabled="deleting"
:text="item.location"
:title="item.location"
category="tertiary"
@@ -97,7 +111,7 @@ export default {
<gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText">
<template #count>
- {{ item.tags_count }}
+ {{ item.tagsCount }}
</template>
</gl-sprintf>
</span>
@@ -106,7 +120,7 @@ export default {
<delete-button
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:disabled="disabledDelete"
- :tooltip-disabled="Boolean(item.destroy_path)"
+ :tooltip-disabled="item.canDelete"
:tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
@delete="$emit('delete', item)"
/>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
index 35eb0b11e40..5308b025cc0 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/project_empty_state.vue
@@ -1,6 +1,5 @@
<script>
import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui';
-import { mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import {
@@ -20,6 +19,7 @@ export default {
GlFormInputGroup,
GlFormInput,
},
+ inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
i18n: {
quickStart: QUICK_START,
copyLoginTitle: COPY_LOGIN_TITLE,
@@ -35,10 +35,6 @@ export default {
'ContainerRegistry|You can add an image to this registry with the following commands:',
),
},
- computed: {
- ...mapState(['config']),
- ...mapGetters(['dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand']),
- },
};
</script>
<template>
diff --git a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
index 666d8b042da..1cedcc41b2b 100644
--- a/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
+++ b/app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue
@@ -1,9 +1,11 @@
<script>
+/* eslint-disable vue/no-v-html */
+// We are forced to use `v-html` untill this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079
+// then we can re-write this to use gl-breadcrumb
import { initial, first, last } from 'lodash';
-import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
+import { sanitize } from '~/lib/dompurify';
export default {
- directives: { SafeHtml },
props: {
crumbs: {
type: Array,
@@ -11,6 +13,9 @@ export default {
},
},
computed: {
+ parsedCrumbs() {
+ return this.crumbs.map(c => ({ ...c, innerHTML: sanitize(c.innerHTML) }));
+ },
rootRoute() {
return this.$router.options.routes.find(r => r.meta.root);
},
@@ -18,11 +23,11 @@ export default {
return this.$route.name === this.rootRoute.name;
},
rootCrumbs() {
- return initial(this.crumbs);
+ return initial(this.parsedCrumbs);
},
divider() {
const { classList, tagName, innerHTML } = first(this.crumbs).querySelector('svg');
- return { classList: [...classList], tagName, innerHTML };
+ return { classList: [...classList], tagName, innerHTML: sanitize(innerHTML) };
},
lastCrumb() {
const { children } = last(this.crumbs);
@@ -30,7 +35,7 @@ export default {
return {
tagName,
className,
- text: this.$route.meta.nameGenerator(this.$store.state),
+ text: this.$route.meta.nameGenerator(),
path: { to: this.$route.name },
};
},
@@ -43,14 +48,14 @@ export default {
<li
v-for="(crumb, index) in rootCrumbs"
:key="index"
- v-safe-html="crumb.innerHTML"
:class="crumb.className"
+ v-html="crumb.innerHTML"
></li>
<li v-if="!isRootRoute">
<router-link ref="rootRouteLink" :to="rootRoute.path">
- {{ rootRoute.meta.nameGenerator($store.state) }}
+ {{ rootRoute.meta.nameGenerator() }}
</router-link>
- <component :is="divider.tagName" v-safe-html="divider.innerHTML" :class="divider.classList" />
+ <component :is="divider.tagName" :class="divider.classList" v-html="divider.innerHTML" />
</li>
<li>
<component :is="lastCrumb.tagName" ref="lastCrumb" :class="lastCrumb.className">
diff --git a/app/assets/javascripts/registry/explorer/constants/details.js b/app/assets/javascripts/registry/explorer/constants/details.js
index 306e6903a4f..1babaaa93da 100644
--- a/app/assets/javascripts/registry/explorer/constants/details.js
+++ b/app/assets/javascripts/registry/explorer/constants/details.js
@@ -56,6 +56,8 @@ export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
'ContainerRegistry|Invalid tag: missing manifest digest',
);
+export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}');
+
export const NOT_AVAILABLE_TEXT = __('N/A');
export const NOT_AVAILABLE_SIZE = __('0 bytes');
// Parameters
diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js
index 39f63d2a153..37ced72861e 100644
--- a/app/assets/javascripts/registry/explorer/constants/list.js
+++ b/app/assets/javascripts/registry/explorer/constants/list.js
@@ -44,5 +44,6 @@ export const EMPTY_RESULT_MESSAGE = s__(
// Parameters
-export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
-export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
+export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
+export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
+export const GRAPHQL_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql b/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql
new file mode 100644
index 00000000000..9a3579ee8e0
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql
@@ -0,0 +1,11 @@
+fragment ContainerRepositoryFields on ContainerRepository {
+ id
+ name
+ path
+ status
+ location
+ canDelete
+ createdAt
+ tagsCount
+ expirationPolicyStartedAt
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/index.js b/app/assets/javascripts/registry/explorer/graphql/index.js
new file mode 100644
index 00000000000..16152eb81f6
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql
new file mode 100644
index 00000000000..4c88b726ee5
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql
@@ -0,0 +1,9 @@
+mutation destroyContainerRepository($id: ContainerRepositoryID!) {
+ destroyContainerRepository(input: { id: $id }) {
+ containerRepository {
+ id
+ status
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql
new file mode 100644
index 00000000000..a31f2829e13
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql
@@ -0,0 +1,5 @@
+mutation destroyContainerRepositoryTags($id: ContainerRepositoryID!, $tagNames: [String!]!) {
+ destroyContainerRepositoryTags(input: { id: $id, tagNames: $tagNames }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql
new file mode 100644
index 00000000000..b40200e020b
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_container_repository_details.query.graphql
@@ -0,0 +1,41 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getContainerRepositoryDetails(
+ $id: ID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ containerRepository(id: $id) {
+ id
+ name
+ path
+ status
+ location
+ canDelete
+ createdAt
+ updatedAt
+ tagsCount
+ expirationPolicyStartedAt
+ tags(after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ digest
+ location
+ path
+ name
+ revision
+ shortRevision
+ createdAt
+ totalSize
+ canDelete
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ project {
+ visibility
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql
new file mode 100644
index 00000000000..348eda97ea7
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql
@@ -0,0 +1,23 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/container_repository.fragment.graphql"
+
+query getGroupContainerRepositories(
+ $fullPath: ID!
+ $name: String
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ group(fullPath: $fullPath) {
+ containerRepositoriesCount
+ containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ ...ContainerRepositoryFields
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql
new file mode 100644
index 00000000000..338e27745f7
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql
@@ -0,0 +1,23 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/container_repository.fragment.graphql"
+
+query getProjectContainerRepositories(
+ $fullPath: ID!
+ $name: String
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ project(fullPath: $fullPath) {
+ containerRepositoriesCount
+ containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ ...ContainerRepositoryFields
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js
index 2bba3ee4ff9..d887b6a1b15 100644
--- a/app/assets/javascripts/registry/explorer/index.js
+++ b/app/assets/javascripts/registry/explorer/index.js
@@ -1,10 +1,11 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
+import { parseBoolean } from '~/lib/utils/common_utils';
import RegistryExplorer from './pages/index.vue';
import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
-import { createStore } from './stores';
import createRouter from './router';
+import { apolloProvider } from './graphql/index';
Vue.use(Translate);
Vue.use(GlToast);
@@ -16,20 +17,42 @@ export default () => {
return null;
}
- const { endpoint } = el.dataset;
+ const { endpoint, expirationPolicy, isGroupPage, isAdmin, ...config } = el.dataset;
- const store = createStore();
- const router = createRouter(endpoint);
- store.dispatch('setInitialState', el.dataset);
+ // This is a mini state to help the breadcrumb have the correct name in the details page
+ const breadCrumbState = Vue.observable({
+ name: '',
+ updateName(value) {
+ this.name = value;
+ },
+ });
+
+ const router = createRouter(endpoint, breadCrumbState);
const attachMainComponent = () =>
new Vue({
el,
- store,
router,
+ apolloProvider,
components: {
RegistryExplorer,
},
+ provide() {
+ return {
+ breadCrumbState,
+ config: {
+ ...config,
+ expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined,
+ isGroupPage: parseBoolean(isGroupPage),
+ isAdmin: parseBoolean(isAdmin),
+ },
+ /* eslint-disable @gitlab/require-i18n-strings */
+ dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`,
+ dockerPushCommand: `docker push ${config.repositoryUrl}`,
+ dockerLoginCommand: `docker login ${config.registryHostUrlWithPort}`,
+ /* eslint-enable @gitlab/require-i18n-strings */
+ };
+ },
render(createElement) {
return createElement('registry-explorer');
},
@@ -40,8 +63,8 @@ export default () => {
const crumbs = [...document.querySelectorAll('.js-breadcrumbs-list li')];
return new Vue({
el: breadCrumbEl,
- store,
router,
+ apolloProvider,
components: {
RegistryBreadcrumb,
},
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index a60ef5c4982..540f02d58d4 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -1,8 +1,9 @@
<script>
-import { mapState, mapActions } from 'vuex';
-import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
+import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import createFlash from '~/flash';
import Tracking from '~/tracking';
+import { joinPaths } from '~/lib/utils/url_utility';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
@@ -11,11 +12,16 @@ import TagsList from '../components/details_page/tags_list.vue';
import TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
+import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
+import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
+
import {
ALERT_SUCCESS_TAG,
ALERT_DANGER_TAG,
ALERT_SUCCESS_TAGS,
ALERT_DANGER_TAGS,
+ GRAPHQL_PAGE_SIZE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
} from '../constants/index';
export default {
@@ -23,28 +29,61 @@ export default {
DeleteAlert,
PartialCleanupAlert,
DetailsHeader,
- GlPagination,
+ GlKeysetPagination,
DeleteModal,
TagsList,
TagsLoader,
EmptyTagsState,
},
+ inject: ['breadCrumbState', 'config'],
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [Tracking.mixin()],
+ apollo: {
+ image: {
+ query: getContainerRepositoryDetailsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data.containerRepository;
+ },
+ result({ data }) {
+ this.tagsPageInfo = data.containerRepository?.tags?.pageInfo;
+ this.breadCrumbState.updateName(data.containerRepository?.name);
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ },
data() {
return {
+ image: {},
+ tagsPageInfo: {},
itemsToBeDeleted: [],
isMobile: false,
+ mutationLoading: false,
deleteAlertType: null,
dismissPartialCleanupWarning: false,
};
},
computed: {
- ...mapState(['tagsPagination', 'isLoading', 'config', 'tags', 'imageDetails']),
+ queryVariables() {
+ return {
+ id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`),
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ },
+ isLoading() {
+ return this.$apollo.queries.image.loading || this.mutationLoading;
+ },
+ tags() {
+ return this.image?.tags?.nodes || [];
+ },
showPartialCleanupWarning() {
- return this.imageDetails?.cleanup_policy_started_at && !this.dismissPartialCleanupWarning;
+ return this.image?.expirationPolicyStartedAt && !this.dismissPartialCleanupWarning;
},
tracking() {
return {
@@ -52,66 +91,78 @@ export default {
this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
- currentPage: {
- get() {
- return this.tagsPagination.page;
- },
- set(page) {
- this.requestTagsList({ page });
- },
+ showPagination() {
+ return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
},
},
- mounted() {
- this.requestImageDetailsAndTagsList(this.$route.params.id);
- },
methods: {
- ...mapActions([
- 'requestTagsList',
- 'requestDeleteTag',
- 'requestDeleteTags',
- 'requestImageDetailsAndTagsList',
- ]),
deleteTags(toBeDeleted) {
this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]);
this.track('click_button');
this.$refs.deleteModal.show();
},
- handleSingleDelete() {
- const [itemToDelete] = this.itemsToBeDeleted;
- this.itemsToBeDeleted = [];
- return this.requestDeleteTag({ tag: itemToDelete })
- .then(() => {
- this.deleteAlertType = ALERT_SUCCESS_TAG;
- })
- .catch(() => {
- this.deleteAlertType = ALERT_DANGER_TAG;
- });
- },
- handleMultipleDelete() {
+ async handleDelete() {
+ this.track('confirm_delete');
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
-
- return this.requestDeleteTags({
- ids: itemsToBeDeleted.map(x => x.name),
- })
- .then(() => {
- this.deleteAlertType = ALERT_SUCCESS_TAGS;
- })
- .catch(() => {
- this.deleteAlertType = ALERT_DANGER_TAGS;
+ this.mutationLoading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: deleteContainerRepositoryTagsMutation,
+ variables: {
+ id: this.queryVariables.id,
+ tagNames: itemsToBeDeleted.map(i => i.name),
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: getContainerRepositoryDetailsQuery,
+ variables: this.queryVariables,
+ },
+ ],
});
- },
- onDeletionConfirmed() {
- this.track('confirm_delete');
- if (this.itemsToBeDeleted.length > 1) {
- this.handleMultipleDelete();
- } else {
- this.handleSingleDelete();
+
+ if (data?.destroyContainerRepositoryTags?.errors[0]) {
+ throw new Error();
+ }
+ this.deleteAlertType =
+ itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS;
+ } catch (e) {
+ this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
}
+
+ this.mutationLoading = false;
},
handleResize() {
this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
},
+ fetchNextPage() {
+ if (this.tagsPageInfo?.hasNextPage) {
+ this.$apollo.queries.image.fetchMore({
+ variables: {
+ after: this.tagsPageInfo?.endCursor,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ }
+ },
+ fetchPreviousPage() {
+ if (this.tagsPageInfo?.hasPreviousPage) {
+ this.$apollo.queries.image.fetchMore({
+ variables: {
+ first: null,
+ before: this.tagsPageInfo?.startCursor,
+ last: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ }
+ },
},
};
</script>
@@ -132,28 +183,30 @@ export default {
@dismiss="dismissPartialCleanupWarning = true"
/>
- <details-header :image-name="imageDetails.name" />
+ <details-header :image="image" />
<tags-loader v-if="isLoading" />
<template v-else>
<empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" />
- <tags-list v-else :tags="tags" :is-mobile="isMobile" @delete="deleteTags" />
+ <template v-else>
+ <tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ :has-next-page="tagsPageInfo.hasNextPage"
+ :has-previous-page="tagsPageInfo.hasPreviousPage"
+ class="gl-mt-3"
+ @prev="fetchPreviousPage"
+ @next="fetchNextPage"
+ />
+ </div>
+ </template>
</template>
- <gl-pagination
- v-if="!isLoading"
- ref="pagination"
- v-model="currentPage"
- :per-page="tagsPagination.perPage"
- :total-items="tagsPagination.total"
- align="center"
- class="gl-w-full gl-mt-3"
- />
-
<delete-modal
ref="deleteModal"
:items-to-be-deleted="itemsToBeDeleted"
- @confirmDelete="onDeletionConfirmed"
+ @confirmDelete="handleDelete"
@cancel="track('cancel_delete')"
/>
</div>
diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue
index 4ac0bca84c1..dca63e1a569 100644
--- a/app/assets/javascripts/registry/explorer/pages/index.vue
+++ b/app/assets/javascripts/registry/explorer/pages/index.vue
@@ -1,7 +1,3 @@
-<script>
-export default {};
-</script>
-
<template>
<div>
<router-view ref="router-view" />
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 81e47073fe9..3192ba82db8 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -1,5 +1,4 @@
<script>
-import { mapState, mapActions } from 'vuex';
import {
GlEmptyState,
GlTooltipDirective,
@@ -11,6 +10,7 @@ import {
GlSearchBoxByClick,
} from '@gitlab/ui';
import Tracking from '~/tracking';
+import createFlash from '~/flash';
import ProjectEmptyState from '../components/list_page/project_empty_state.vue';
import GroupEmptyState from '../components/list_page/group_empty_state.vue';
@@ -18,6 +18,10 @@ import RegistryHeader from '../components/list_page/registry_header.vue';
import ImageList from '../components/list_page/image_list.vue';
import CliCommands from '../components/list_page/cli_commands.vue';
+import getProjectContainerRepositoriesQuery from '../graphql/queries/get_project_container_repositories.query.graphql';
+import getGroupContainerRepositoriesQuery from '../graphql/queries/get_group_container_repositories.query.graphql';
+import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql';
+
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
@@ -29,6 +33,8 @@ import {
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
+ GRAPHQL_PAGE_SIZE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
} from '../constants/index';
export default {
@@ -47,6 +53,7 @@ export default {
RegistryHeader,
CliCommands,
},
+ inject: ['config'],
directives: {
GlTooltip: GlTooltipDirective,
},
@@ -66,21 +73,62 @@ export default {
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
},
+ apollo: {
+ images: {
+ query() {
+ return this.graphQlQuery;
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data[this.graphqlResource]?.containerRepositories.nodes;
+ },
+ result({ data }) {
+ this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo;
+ this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount;
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ },
data() {
return {
+ images: [],
+ pageInfo: {},
+ containerRepositoriesCount: 0,
itemToDelete: {},
deleteAlertType: null,
- search: null,
- isEmpty: false,
+ searchValue: null,
+ name: null,
+ mutationLoading: false,
};
},
computed: {
- ...mapState(['config', 'isLoading', 'images', 'pagination']),
+ graphqlResource() {
+ return this.config.isGroupPage ? 'group' : 'project';
+ },
+ graphQlQuery() {
+ return this.config.isGroupPage
+ ? getGroupContainerRepositoriesQuery
+ : getProjectContainerRepositoriesQuery;
+ },
+ queryVariables() {
+ return {
+ name: this.name,
+ fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ },
tracking() {
return {
label: 'registry_repository_delete',
};
},
+ isLoading() {
+ return this.$apollo.queries.images.loading || this.mutationLoading;
+ },
showCommands() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
@@ -93,19 +141,7 @@ export default {
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
- mounted() {
- this.loadImageList(this.$route.name);
- },
methods: {
- ...mapActions(['requestImagesList', 'requestDeleteImage']),
- loadImageList(fromName) {
- if (!fromName || !this.images?.length) {
- return this.requestImagesList().then(() => {
- this.isEmpty = this.images.length === 0;
- });
- }
- return Promise.resolve();
- },
deleteImage(item) {
this.track('click_button');
this.itemToDelete = item;
@@ -113,18 +149,59 @@ export default {
},
handleDeleteImage() {
this.track('confirm_delete');
- return this.requestDeleteImage(this.itemToDelete)
- .then(() => {
- this.deleteAlertType = 'success';
+ this.mutationLoading = true;
+ return this.$apollo
+ .mutate({
+ mutation: deleteContainerRepositoryMutation,
+ variables: {
+ id: this.itemToDelete.id,
+ },
+ })
+ .then(({ data }) => {
+ if (data?.destroyContainerRepository?.errors[0]) {
+ this.deleteAlertType = 'danger';
+ } else {
+ this.deleteAlertType = 'success';
+ }
})
.catch(() => {
this.deleteAlertType = 'danger';
+ })
+ .finally(() => {
+ this.mutationLoading = false;
});
},
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
},
+ fetchNextPage() {
+ if (this.pageInfo?.hasNextPage) {
+ this.$apollo.queries.images.fetchMore({
+ variables: {
+ after: this.pageInfo?.endCursor,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ }
+ },
+ fetchPreviousPage() {
+ if (this.pageInfo?.hasPreviousPage) {
+ this.$apollo.queries.images.fetchMore({
+ variables: {
+ first: null,
+ before: this.pageInfo?.startCursor,
+ last: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ }
+ },
},
};
</script>
@@ -134,7 +211,7 @@ export default {
<gl-alert
v-if="showDeleteAlert"
:variant="deleteAlertType"
- class="mt-2"
+ class="gl-mt-5"
dismissible
@dismiss="dismissDeleteAlert"
>
@@ -165,7 +242,7 @@ export default {
<template v-else>
<registry-header
- :images-count="pagination.total"
+ :images-count="containerRepositoriesCount"
:expiration-policy="config.expirationPolicy"
:help-page-path="config.helpPagePath"
:expiration-policy-help-page-path="config.expirationPolicyHelpPagePath"
@@ -176,7 +253,7 @@ export default {
</template>
</registry-header>
- <div v-if="isLoading" class="mt-2">
+ <div v-if="isLoading" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
@@ -190,16 +267,17 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <template v-if="!isEmpty">
+ <template v-if="images.length > 0 || name">
<div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader">
<div class="gl-flex-fill-1">
<h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
</div>
<div>
<gl-search-box-by-click
- v-model="search"
+ v-model="searchValue"
:placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
- @submit="requestImagesList({ name: $event })"
+ @clear="name = null"
+ @submit="name = $event"
/>
</div>
</div>
@@ -207,9 +285,10 @@ export default {
<image-list
v-if="images.length"
:images="images"
- :pagination="pagination"
- @pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
+ :page-info="pageInfo"
@delete="deleteImage"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
/>
<gl-empty-state
diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js
index dcf1c77329d..d8903cf0931 100644
--- a/app/assets/javascripts/registry/explorer/router.js
+++ b/app/assets/javascripts/registry/explorer/router.js
@@ -6,7 +6,7 @@ import { CONTAINER_REGISTRY_TITLE } from './constants/index';
Vue.use(VueRouter);
-export default function createRouter(base) {
+export default function createRouter(base, breadCrumbState) {
const router = new VueRouter({
base,
mode: 'history',
@@ -25,7 +25,7 @@ export default function createRouter(base) {
path: '/:id',
component: Details,
meta: {
- nameGenerator: ({ imageDetails }) => imageDetails?.name,
+ nameGenerator: () => breadCrumbState.name,
},
},
],
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
deleted file mode 100644
index c1883095097..00000000000
--- a/app/assets/javascripts/registry/explorer/stores/actions.js
+++ /dev/null
@@ -1,119 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-import createFlash from '~/flash';
-import Api from '~/api';
-import * as types from './mutation_types';
-import {
- FETCH_IMAGES_LIST_ERROR_MESSAGE,
- DEFAULT_PAGE,
- DEFAULT_PAGE_SIZE,
- FETCH_TAGS_LIST_ERROR_MESSAGE,
- FETCH_IMAGE_DETAILS_ERROR_MESSAGE,
-} from '../constants/index';
-import { pathGenerator } from '../utils';
-
-export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
-export const setShowGarbageCollectionTip = ({ commit }, data) =>
- commit(types.SET_SHOW_GARBAGE_COLLECTION_TIP, data);
-
-export const receiveImagesListSuccess = ({ commit }, { data, headers }) => {
- commit(types.SET_IMAGES_LIST_SUCCESS, data);
- commit(types.SET_PAGINATION, headers);
-};
-
-export const receiveTagsListSuccess = ({ commit }, { data, headers }) => {
- commit(types.SET_TAGS_LIST_SUCCESS, data);
- commit(types.SET_TAGS_PAGINATION, headers);
-};
-
-export const requestImagesList = (
- { commit, dispatch, state },
- { pagination = {}, name = null } = {},
-) => {
- commit(types.SET_MAIN_LOADING, true);
- const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
-
- return axios
- .get(state.config.endpoint, { params: { page, per_page: perPage, name } })
- .then(({ data, headers }) => {
- dispatch('receiveImagesListSuccess', { data, headers });
- })
- .catch(() => {
- createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
- })
- .finally(() => {
- commit(types.SET_MAIN_LOADING, false);
- });
-};
-
-export const requestTagsList = ({ commit, dispatch, state: { imageDetails } }, pagination = {}) => {
- commit(types.SET_MAIN_LOADING, true);
- const tagsPath = pathGenerator(imageDetails);
-
- const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
- return axios
- .get(tagsPath, { params: { page, per_page: perPage } })
- .then(({ data, headers }) => {
- dispatch('receiveTagsListSuccess', { data, headers });
- })
- .catch(() => {
- createFlash({ message: FETCH_TAGS_LIST_ERROR_MESSAGE });
- })
- .finally(() => {
- commit(types.SET_MAIN_LOADING, false);
- });
-};
-
-export const requestImageDetailsAndTagsList = ({ dispatch, commit }, id) => {
- commit(types.SET_MAIN_LOADING, true);
- return Api.containerRegistryDetails(id)
- .then(({ data }) => {
- commit(types.SET_IMAGE_DETAILS, data);
- dispatch('requestTagsList');
- })
- .catch(() => {
- createFlash({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE });
- commit(types.SET_MAIN_LOADING, false);
- });
-};
-
-export const requestDeleteTag = ({ commit, dispatch, state }, { tag }) => {
- commit(types.SET_MAIN_LOADING, true);
- return axios
- .delete(tag.destroy_path)
- .then(() => {
- dispatch('setShowGarbageCollectionTip', true);
-
- return dispatch('requestTagsList', state.tagsPagination);
- })
- .finally(() => {
- commit(types.SET_MAIN_LOADING, false);
- });
-};
-
-export const requestDeleteTags = ({ commit, dispatch, state }, { ids }) => {
- commit(types.SET_MAIN_LOADING, true);
-
- const tagsPath = pathGenerator(state.imageDetails, '/bulk_destroy');
-
- return axios
- .delete(tagsPath, { params: { ids } })
- .then(() => {
- dispatch('setShowGarbageCollectionTip', true);
- return dispatch('requestTagsList', state.tagsPagination);
- })
- .finally(() => {
- commit(types.SET_MAIN_LOADING, false);
- });
-};
-
-export const requestDeleteImage = ({ commit }, image) => {
- commit(types.SET_MAIN_LOADING, true);
- return axios
- .delete(image.destroy_path)
- .then(() => {
- commit(types.UPDATE_IMAGE, { ...image, deleting: true });
- })
- .finally(() => {
- commit(types.SET_MAIN_LOADING, false);
- });
-};
diff --git a/app/assets/javascripts/registry/explorer/stores/getters.js b/app/assets/javascripts/registry/explorer/stores/getters.js
deleted file mode 100644
index 7b5d1bd6da3..00000000000
--- a/app/assets/javascripts/registry/explorer/stores/getters.js
+++ /dev/null
@@ -1,18 +0,0 @@
-export const dockerBuildCommand = state => {
- /* eslint-disable @gitlab/require-i18n-strings */
- return `docker build -t ${state.config.repositoryUrl} .`;
-};
-
-export const dockerPushCommand = state => {
- /* eslint-disable @gitlab/require-i18n-strings */
- return `docker push ${state.config.repositoryUrl}`;
-};
-
-export const dockerLoginCommand = state => {
- /* eslint-disable @gitlab/require-i18n-strings */
- return `docker login ${state.config.registryHostUrlWithPort}`;
-};
-
-export const showGarbageCollection = state => {
- return state.showGarbageCollectionTip && state.config.isAdmin;
-};
diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js
deleted file mode 100644
index 18e3351ed13..00000000000
--- a/app/assets/javascripts/registry/explorer/stores/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-import state from './state';
-
-Vue.use(Vuex);
-
-export const createStore = () =>
- new Vuex.Store({
- state,
- getters,
- actions,
- mutations,
- });
diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
deleted file mode 100644
index 5dd0cec52eb..00000000000
--- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
-
-export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
-export const UPDATE_IMAGE = 'UPDATE_IMAGE';
-export const SET_PAGINATION = 'SET_PAGINATION';
-export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
-export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION';
-export const SET_TAGS_LIST_SUCCESS = 'SET_TAGS_LIST_SUCCESS';
-export const SET_SHOW_GARBAGE_COLLECTION_TIP = 'SET_SHOW_GARBAGE_COLLECTION_TIP';
-export const SET_IMAGE_DETAILS = 'SET_IMAGE_DETAILS';
diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js
deleted file mode 100644
index 5bdb431ad2e..00000000000
--- a/app/assets/javascripts/registry/explorer/stores/mutations.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import * as types from './mutation_types';
-import { parseIntPagination, normalizeHeaders, parseBoolean } from '~/lib/utils/common_utils';
-import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants/index';
-
-export default {
- [types.SET_INITIAL_STATE](state, config) {
- state.config = {
- ...config,
- expirationPolicy: config.expirationPolicy ? JSON.parse(config.expirationPolicy) : undefined,
- isGroupPage: parseBoolean(config.isGroupPage),
- isAdmin: parseBoolean(config.isAdmin),
- };
- },
-
- [types.SET_IMAGES_LIST_SUCCESS](state, images) {
- state.images = images.map(i => ({
- ...i,
- status: undefined,
- deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS,
- failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS,
- }));
- },
-
- [types.UPDATE_IMAGE](state, image) {
- const index = state.images.findIndex(i => i.id === image.id);
- state.images.splice(index, 1, { ...image });
- },
-
- [types.SET_TAGS_LIST_SUCCESS](state, tags) {
- state.tags = tags;
- },
-
- [types.SET_MAIN_LOADING](state, isLoading) {
- state.isLoading = isLoading;
- },
-
- [types.SET_SHOW_GARBAGE_COLLECTION_TIP](state, showGarbageCollectionTip) {
- state.showGarbageCollectionTip = showGarbageCollectionTip;
- },
-
- [types.SET_PAGINATION](state, headers) {
- const normalizedHeaders = normalizeHeaders(headers);
- state.pagination = parseIntPagination(normalizedHeaders);
- },
-
- [types.SET_TAGS_PAGINATION](state, headers) {
- const normalizedHeaders = normalizeHeaders(headers);
- state.tagsPagination = parseIntPagination(normalizedHeaders);
- },
-
- [types.SET_IMAGE_DETAILS](state, details) {
- state.imageDetails = details;
- },
-};
diff --git a/app/assets/javascripts/registry/explorer/stores/state.js b/app/assets/javascripts/registry/explorer/stores/state.js
deleted file mode 100644
index 66ee56eb47b..00000000000
--- a/app/assets/javascripts/registry/explorer/stores/state.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default () => ({
- isLoading: false,
- showGarbageCollectionTip: false,
- config: {},
- images: [],
- imageDetails: {},
- tags: [],
- pagination: {},
- tagsPagination: {},
-});
diff --git a/app/assets/javascripts/registry/explorer/utils.js b/app/assets/javascripts/registry/explorer/utils.js
deleted file mode 100644
index a48da51caae..00000000000
--- a/app/assets/javascripts/registry/explorer/utils.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { joinPaths } from '~/lib/utils/url_utility';
-
-export const pathGenerator = (imageDetails, ending = '?format=json') => {
- // this method is a temporary workaround, to be removed with graphql implementation
- // https://gitlab.com/gitlab-org/gitlab/-/issues/276432
-
- const splitPath = imageDetails.path.split('/').reverse();
- const splitName = imageDetails.name ? imageDetails.name.split('/').reverse() : [];
- const basePath = splitPath
- .reduce((acc, curr, index) => {
- if (splitPath[index] !== splitName[index]) {
- acc.unshift(curr);
- }
- return acc;
- }, [])
- .join('/');
-
- return joinPaths(
- window.gon.relative_url_root,
- `/${basePath}`,
- '/registry/repository/',
- `${imageDetails.id}`,
- `tags${ending}`,
- );
-};
diff --git a/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue b/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue
new file mode 100644
index 00000000000..d75fb31fd98
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/components/expiration_dropdown.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormSelect,
+ },
+ props: {
+ formOptions: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group :id="`${name}-form-group`" :label-for="name" :label="label">
+ <gl-form-select :id="name" :value="value" :disabled="disabled" @input="$emit('input', $event)">
+ <option
+ v-for="option in formOptions"
+ :key="option.key"
+ :value="option.key"
+ data-testid="option"
+ >
+ {{ option.label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/registry/settings/components/expiration_input.vue b/app/assets/javascripts/registry/settings/components/expiration_input.vue
new file mode 100644
index 00000000000..2dbd9d26f60
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/components/expiration_input.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlFormGroup, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui';
+import { NAME_REGEX_LENGTH, TEXT_AREA_INVALID_FEEDBACK } from '../constants';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlSprintf,
+ GlLink,
+ },
+ inject: ['tagsRegexHelpPagePath'],
+ props: {
+ error: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ name: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ description: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ textAreaLengthErrorMessage() {
+ return this.isInputValid(this.value) ? '' : TEXT_AREA_INVALID_FEEDBACK;
+ },
+ inputValidation() {
+ const nameRegexErrors = this.error || this.textAreaLengthErrorMessage;
+ return {
+ state: nameRegexErrors === null ? null : !nameRegexErrors,
+ message: nameRegexErrors,
+ };
+ },
+ internalValue: {
+ get() {
+ return this.value;
+ },
+ set(value) {
+ this.$emit('input', value);
+ this.$emit('validation', this.isInputValid(value));
+ },
+ },
+ },
+ methods: {
+ isInputValid(value) {
+ return !value || value.length <= NAME_REGEX_LENGTH;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ :id="`${name}-form-group`"
+ :label-for="name"
+ :state="inputValidation.state"
+ :invalid-feedback="inputValidation.message"
+ >
+ <template #label>
+ <span data-testid="label">
+ <gl-sprintf :message="label">
+ <template #italic="{content}">
+ <i>{{ content }}</i>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <gl-form-input
+ :id="name"
+ v-model="internalValue"
+ :placeholder="placeholder"
+ :state="inputValidation.state"
+ :disabled="disabled"
+ trim
+ />
+ <template #description>
+ <span data-testid="description" class="gl-text-gray-400">
+ <gl-sprintf :message="description">
+ <template #link="{content}">
+ <gl-link :href="tagsRegexHelpPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue
new file mode 100644
index 00000000000..fd9ca6a54c5
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+import { NEXT_CLEANUP_LABEL, NOT_SCHEDULED_POLICY_TEXT } from '~/registry/settings/constants';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: NOT_SCHEDULED_POLICY_TEXT,
+ },
+ enabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ parsedValue() {
+ return this.enabled ? this.value : NOT_SCHEDULED_POLICY_TEXT;
+ },
+ },
+ i18n: {
+ NEXT_CLEANUP_LABEL,
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ id="expiration-policy-info-text-group"
+ :label="$options.i18n.NEXT_CLEANUP_LABEL"
+ label-for="expiration-policy-info-text"
+ >
+ <gl-form-input
+ id="expiration-policy-info-text"
+ class="gl-pl-0!"
+ plaintext
+ :value="parsedValue"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
new file mode 100644
index 00000000000..7f045244926
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui';
+import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_TOGGLE_DESCRIPTION } from '../constants';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlToggle,
+ GlSprintf,
+ },
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ value: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ enabled: {
+ get() {
+ return this.value;
+ },
+ set(value) {
+ this.$emit('input', value);
+ },
+ },
+ toggleText() {
+ return this.enabled ? ENABLED_TOGGLE_DESCRIPTION : DISABLED_TOGGLE_DESCRIPTION;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-form-group id="expiration-policy-toggle-group" label-for="expiration-policy-toggle">
+ <div class="gl-display-flex">
+ <gl-toggle id="expiration-policy-toggle" v-model="enabled" :disabled="disabled" />
+ <span class="gl-ml-5 gl-line-height-24" data-testid="description">
+ <gl-sprintf :message="toggleText">
+ <template #strong="{content}">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
index 264d39a406a..35c7a8be4ea 100644
--- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
+++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
@@ -1,17 +1,17 @@
<script>
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { isEqual, get } from 'lodash';
-import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql';
-import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants';
-
-import SettingsForm from './settings_form.vue';
+import { isEqual, get, isEmpty } from 'lodash';
+import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.query.graphql';
import {
+ FETCH_SETTINGS_ERROR_MESSAGE,
UNAVAILABLE_FEATURE_TITLE,
UNAVAILABLE_FEATURE_INTRO_TEXT,
UNAVAILABLE_USER_FEATURE_TEXT,
UNAVAILABLE_ADMIN_FEATURE_TEXT,
} from '../constants';
+import SettingsForm from './settings_form.vue';
+
export default {
components: {
SettingsForm,
@@ -60,6 +60,9 @@ export default {
return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
},
isEdited() {
+ if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
+ return false;
+ }
return !isEqual(this.containerExpirationPolicy, this.workingCopy);
},
},
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
index fe4aee6806e..1f374c7b60e 100644
--- a/app/assets/javascripts/registry/settings/components/settings_form.vue
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -1,21 +1,41 @@
<script>
-import { GlCard, GlButton } from '@gitlab/ui';
+import { GlCard, GlButton, GlSprintf } from '@gitlab/ui';
import Tracking from '~/tracking';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
-} from '../../shared/constants';
-import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue';
-import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants';
-import { formOptionsGenerator } from '~/registry/shared/utils';
-import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql';
-import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update';
+ SET_CLEANUP_POLICY_BUTTON,
+ KEEP_HEADER_TEXT,
+ KEEP_INFO_TEXT,
+ KEEP_N_LABEL,
+ NAME_REGEX_KEEP_LABEL,
+ NAME_REGEX_KEEP_DESCRIPTION,
+ REMOVE_HEADER_TEXT,
+ REMOVE_INFO_TEXT,
+ EXPIRATION_SCHEDULE_LABEL,
+ NAME_REGEX_LABEL,
+ NAME_REGEX_PLACEHOLDER,
+ NAME_REGEX_DESCRIPTION,
+ CADENCE_LABEL,
+ EXPIRATION_POLICY_FOOTER_NOTE,
+} from '~/registry/settings/constants';
+import { formOptionsGenerator } from '~/registry/settings/utils';
+import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql';
+import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update';
+import ExpirationDropdown from './expiration_dropdown.vue';
+import ExpirationInput from './expiration_input.vue';
+import ExpirationToggle from './expiration_toggle.vue';
+import ExpirationRunText from './expiration_run_text.vue';
export default {
components: {
GlCard,
GlButton,
- ExpirationPolicyFields,
+ GlSprintf,
+ ExpirationDropdown,
+ ExpirationInput,
+ ExpirationToggle,
+ ExpirationRunText,
},
mixins: [Tracking.mixin()],
inject: ['projectPath'],
@@ -35,22 +55,31 @@ export default {
default: false,
},
},
- labelsConfig: {
- cols: 3,
- align: 'right',
- },
+
formOptions: formOptionsGenerator(),
i18n: {
- CLEANUP_POLICY_CARD_HEADER,
+ KEEP_HEADER_TEXT,
+ KEEP_INFO_TEXT,
+ KEEP_N_LABEL,
+ NAME_REGEX_KEEP_LABEL,
SET_CLEANUP_POLICY_BUTTON,
+ NAME_REGEX_KEEP_DESCRIPTION,
+ REMOVE_HEADER_TEXT,
+ REMOVE_INFO_TEXT,
+ EXPIRATION_SCHEDULE_LABEL,
+ NAME_REGEX_LABEL,
+ NAME_REGEX_PLACEHOLDER,
+ NAME_REGEX_DESCRIPTION,
+ CADENCE_LABEL,
+ EXPIRATION_POLICY_FOOTER_NOTE,
},
data() {
return {
tracking: {
label: 'docker_container_retention_and_expiration_policies',
},
- fieldsAreValid: true,
- apiErrors: null,
+ apiErrors: {},
+ localErrors: {},
mutationLoading: false,
};
},
@@ -66,12 +95,18 @@ export default {
showLoadingIcon() {
return this.isLoading || this.mutationLoading;
},
+ fieldsAreValid() {
+ return Object.values(this.localErrors).every(error => error);
+ },
isSubmitButtonDisabled() {
return !this.fieldsAreValid || this.showLoadingIcon;
},
isCancelButtonDisabled() {
return !this.isEdited || this.isLoading || this.mutationLoading;
},
+ isFieldDisabled() {
+ return this.showLoadingIcon || !this.value.enabled;
+ },
mutationVariables() {
return {
projectPath: this.projectPath,
@@ -90,7 +125,8 @@ export default {
},
reset() {
this.track('reset_form');
- this.apiErrors = null;
+ this.apiErrors = {};
+ this.localErrors = {};
this.$emit('reset');
},
setApiErrors(response) {
@@ -101,9 +137,15 @@ export default {
return acc;
}, {});
},
+ setLocalErrors(state, model) {
+ this.localErrors = {
+ ...this.localErrors,
+ [model]: state,
+ };
+ },
submit() {
this.track('submit_form');
- this.apiErrors = null;
+ this.apiErrors = {};
this.mutationLoading = true;
return this.$apollo
.mutate({
@@ -129,11 +171,9 @@ export default {
this.mutationLoading = false;
});
},
- onModelChange(changePayload) {
- this.$emit('input', changePayload.newValue);
- if (this.apiErrors) {
- this.apiErrors[changePayload.modified] = undefined;
- }
+ onModelChange(newValue, model) {
+ this.$emit('input', { ...this.value, [model]: newValue });
+ this.apiErrors[model] = undefined;
},
},
};
@@ -141,42 +181,133 @@ export default {
<template>
<form ref="form-element" @submit.prevent="submit" @reset.prevent="reset">
- <gl-card>
+ <expiration-toggle
+ :value="prefilledForm.enabled"
+ :disabled="showLoadingIcon"
+ class="gl-mb-0!"
+ data-testid="enable-toggle"
+ @input="onModelChange($event, 'enabled')"
+ />
+
+ <div class="gl-display-flex gl-mt-7">
+ <expiration-dropdown
+ v-model="prefilledForm.cadence"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.cadence"
+ :label="$options.i18n.CADENCE_LABEL"
+ name="cadence"
+ class="gl-mr-7 gl-mb-0!"
+ data-testid="cadence-dropdown"
+ @input="onModelChange($event, 'cadence')"
+ />
+ <expiration-run-text
+ :value="prefilledForm.nextRunAt"
+ :enabled="prefilledForm.enabled"
+ class="gl-mb-0!"
+ />
+ </div>
+ <gl-card class="gl-mt-7">
<template #header>
- {{ $options.i18n.CLEANUP_POLICY_CARD_HEADER }}
+ {{ $options.i18n.KEEP_HEADER_TEXT }}
</template>
<template #default>
- <expiration-policy-fields
- :value="prefilledForm"
- :form-options="$options.formOptions"
- :is-loading="isLoading"
- :api-errors="apiErrors"
- @validated="fieldsAreValid = true"
- @invalidated="fieldsAreValid = false"
- @input="onModelChange"
- />
+ <div>
+ <p>
+ <gl-sprintf :message="$options.i18n.KEEP_INFO_TEXT">
+ <template #strong="{content}">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #secondStrong="{content}">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <expiration-dropdown
+ v-model="prefilledForm.keepN"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.keepN"
+ :label="$options.i18n.KEEP_N_LABEL"
+ name="keep-n"
+ data-testid="keep-n-dropdown"
+ @input="onModelChange($event, 'keepN')"
+ />
+ <expiration-input
+ v-model="prefilledForm.nameRegexKeep"
+ :error="apiErrors.nameRegexKeep"
+ :disabled="isFieldDisabled"
+ :label="$options.i18n.NAME_REGEX_KEEP_LABEL"
+ :description="$options.i18n.NAME_REGEX_KEEP_DESCRIPTION"
+ name="keep-regex"
+ data-testid="keep-regex-input"
+ @input="onModelChange($event, 'nameRegexKeep')"
+ @validation="setLocalErrors($event, 'nameRegexKeep')"
+ />
+ </div>
</template>
- <template #footer>
- <gl-button
- ref="cancel-button"
- type="reset"
- class="gl-mr-3 gl-display-block float-right"
- :disabled="isCancelButtonDisabled"
- >
- {{ __('Cancel') }}
- </gl-button>
- <gl-button
- ref="save-button"
- type="submit"
- :disabled="isSubmitButtonDisabled"
- :loading="showLoadingIcon"
- variant="success"
- category="primary"
- class="js-no-auto-disable"
- >
- {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
- </gl-button>
+ </gl-card>
+ <gl-card class="gl-mt-7">
+ <template #header>
+ {{ $options.i18n.REMOVE_HEADER_TEXT }}
+ </template>
+ <template #default>
+ <div>
+ <p>
+ <gl-sprintf :message="$options.i18n.REMOVE_INFO_TEXT">
+ <template #strong="{content}">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #secondStrong="{content}">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <expiration-dropdown
+ v-model="prefilledForm.olderThan"
+ :disabled="isFieldDisabled"
+ :form-options="$options.formOptions.olderThan"
+ :label="$options.i18n.EXPIRATION_SCHEDULE_LABEL"
+ name="older-than"
+ data-testid="older-than-dropdown"
+ @input="onModelChange($event, 'olderThan')"
+ />
+ <expiration-input
+ v-model="prefilledForm.nameRegex"
+ :error="apiErrors.nameRegex"
+ :disabled="isFieldDisabled"
+ :label="$options.i18n.NAME_REGEX_LABEL"
+ :placeholder="$options.i18n.NAME_REGEX_PLACEHOLDER"
+ :description="$options.i18n.NAME_REGEX_DESCRIPTION"
+ name="remove-regex"
+ data-testid="remove-regex-input"
+ @input="onModelChange($event, 'nameRegex')"
+ @validation="setLocalErrors($event, 'nameRegex')"
+ />
+ </div>
</template>
</gl-card>
+ <div class="gl-mt-7 gl-display-flex gl-align-items-center">
+ <gl-button
+ data-testid="save-button"
+ type="submit"
+ :disabled="isSubmitButtonDisabled"
+ :loading="showLoadingIcon"
+ variant="success"
+ category="primary"
+ class="js-no-auto-disable gl-mr-4"
+ >
+ {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
+ </gl-button>
+ <gl-button
+ data-testid="cancel-button"
+ type="reset"
+ :disabled="isCancelButtonDisabled"
+ class="gl-mr-4"
+ >
+ {{ __('Cancel') }}
+ </gl-button>
+ <span class="gl-font-style-italic gl-text-gray-400">{{
+ $options.i18n.EXPIRATION_POLICY_FOOTER_NOTE
+ }}</span>
+ </div>
</form>
</template>
diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js
index e790658f491..21c54299632 100644
--- a/app/assets/javascripts/registry/settings/constants.js
+++ b/app/assets/javascripts/registry/settings/constants.js
@@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
-export const SET_CLEANUP_POLICY_BUTTON = s__('ContainerRegistry|Set cleanup policy');
-export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy');
+export const SET_CLEANUP_POLICY_BUTTON = __('Save');
export const UNAVAILABLE_FEATURE_TITLE = s__(
`ContainerRegistry|Cleanup policy for tags is disabled`,
);
@@ -12,3 +11,81 @@ export const UNAVAILABLE_USER_FEATURE_TEXT = __(`Please contact your administrat
export const UNAVAILABLE_ADMIN_FEATURE_TEXT = s__(
`ContainerRegistry| Please visit the %{linkStart}administration settings%{linkEnd} to enable this feature.`,
);
+
+export const TEXT_AREA_INVALID_FEEDBACK = s__(
+ 'ContainerRegistry|The value of this input should be less than 256 characters',
+);
+
+export const KEEP_HEADER_TEXT = s__('ContainerRegistry|Keep these tags');
+export const KEEP_INFO_TEXT = s__(
+ 'ContainerRegistry|Tags that match these rules are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag is always kept.',
+);
+export const KEEP_N_LABEL = s__('ContainerRegistry|Keep the most recent:');
+export const NAME_REGEX_KEEP_LABEL = s__('ContainerRegistry|Keep tags matching:');
+export const NAME_REGEX_KEEP_DESCRIPTION = s__(
+ 'ContainerRegistry|Tags with names that match this regex pattern are kept. %{linkStart}More information%{linkEnd}',
+);
+
+export const REMOVE_HEADER_TEXT = s__('ContainerRegistry|Remove these tags');
+export const REMOVE_INFO_TEXT = s__(
+ 'ContainerRegistry|Tags that match these rules are %{strongStart}removed%{strongEnd}, unless a rule above says to keep them.',
+);
+export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Remove tags older than:');
+export const NAME_REGEX_LABEL = s__('ContainerRegistry|Remove tags matching:');
+export const NAME_REGEX_PLACEHOLDER = '.*';
+export const NAME_REGEX_DESCRIPTION = s__(
+ 'ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}More information%{linkEnd}',
+);
+
+export const ENABLED_TOGGLE_DESCRIPTION = s__(
+ 'ContainerRegistry|%{strongStart}Enabled%{strongEnd} - Tags that match the rules on this page are automatically scheduled for deletion.',
+);
+export const DISABLED_TOGGLE_DESCRIPTION = s__(
+ 'ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted.',
+);
+
+export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup:');
+
+export const NEXT_CLEANUP_LABEL = s__('ContainerRegistry|Next cleanup scheduled to run on:');
+export const NOT_SCHEDULED_POLICY_TEXT = s__('ContainerRegistry|Not yet scheduled');
+export const EXPIRATION_POLICY_FOOTER_NOTE = s__(
+ 'ContainerRegistry|Note: Any policy update will result in a change to the scheduled run date and time',
+);
+
+export const KEEP_N_OPTIONS = [
+ { key: 'ONE_TAG', variable: 1, default: false },
+ { key: 'FIVE_TAGS', variable: 5, default: false },
+ { key: 'TEN_TAGS', variable: 10, default: true },
+ { key: 'TWENTY_FIVE_TAGS', variable: 25, default: false },
+ { key: 'FIFTY_TAGS', variable: 50, default: false },
+ { key: 'ONE_HUNDRED_TAGS', variable: 100, default: false },
+];
+
+export const CADENCE_OPTIONS = [
+ { key: 'EVERY_DAY', label: __('Every day'), default: true },
+ { key: 'EVERY_WEEK', label: __('Every week'), default: false },
+ { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false },
+ { key: 'EVERY_MONTH', label: __('Every month'), default: false },
+ { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false },
+];
+
+export const OLDER_THAN_OPTIONS = [
+ { key: 'SEVEN_DAYS', variable: 7, default: false },
+ { key: 'FOURTEEN_DAYS', variable: 14, default: false },
+ { key: 'THIRTY_DAYS', variable: 30, default: false },
+ { key: 'NINETY_DAYS', variable: 90, default: true },
+];
+
+export const FETCH_SETTINGS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the cleanup policy.',
+);
+
+export const UPDATE_SETTINGS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while updating the cleanup policy.',
+);
+
+export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Cleanup policy successfully saved.',
+);
+
+export const NAME_REGEX_LENGTH = 255;
diff --git a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
index 224e0ed9472..1d6c89133af 100644
--- a/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
+++ b/app/assets/javascripts/registry/settings/graphql/fragments/container_expiration_policy.fragment.graphql
@@ -5,4 +5,5 @@ fragment ContainerExpirationPolicyFields on ContainerExpirationPolicy {
nameRegex
nameRegexKeep
olderThan
+ nextRunAt
}
diff --git a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql
index c40cd115ab0..c40cd115ab0 100644
--- a/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.graphql
+++ b/app/assets/javascripts/registry/settings/graphql/mutations/update_container_expiration_policy.mutation.graphql
diff --git a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql
index c171be0ad07..c171be0ad07 100644
--- a/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.graphql
+++ b/app/assets/javascripts/registry/settings/graphql/queries/get_expiration_policy.query.graphql
diff --git a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
index 88067d52b51..05b4125a2fc 100644
--- a/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
+++ b/app/assets/javascripts/registry/settings/graphql/utils/cache_update.js
@@ -1,5 +1,5 @@
import { produce } from 'immer';
-import expirationPolicyQuery from '../queries/get_expiration_policy.graphql';
+import expirationPolicyQuery from '../queries/get_expiration_policy.query.graphql';
export const updateContainerExpirationPolicy = projectPath => (client, { data: updatedData }) => {
const queryAndParams = {
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
index f7b1c5abd3a..6a4584b1b28 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -13,7 +13,13 @@ export default () => {
if (!el) {
return null;
}
- const { projectPath, isAdmin, adminSettingsPath, enableHistoricEntries } = el.dataset;
+ const {
+ isAdmin,
+ enableHistoricEntries,
+ projectPath,
+ adminSettingsPath,
+ tagsRegexHelpPagePath,
+ } = el.dataset;
return new Vue({
el,
apolloProvider,
@@ -21,10 +27,11 @@ export default () => {
RegistrySettingsApp,
},
provide: {
- projectPath,
isAdmin: parseBoolean(isAdmin),
- adminSettingsPath,
enableHistoricEntries: parseBoolean(enableHistoricEntries),
+ projectPath,
+ adminSettingsPath,
+ tagsRegexHelpPagePath,
},
render(createElement) {
return createElement('registry-settings-app', {});
diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/settings/utils.js
index bdf1ab9507d..51b4fb6bdb8 100644
--- a/app/assets/javascripts/registry/shared/utils.js
+++ b/app/assets/javascripts/registry/settings/utils.js
@@ -6,27 +6,7 @@ export const findDefaultOption = options => {
return item ? item.key : null;
};
-export const mapComputedToEvent = (list, root) => {
- const result = {};
- list.forEach(e => {
- result[e] = {
- get() {
- return this[root][e];
- },
- set(value) {
- this.$emit('input', { newValue: { ...this[root], [e]: value }, modified: e });
- },
- };
- });
- return result;
-};
-
-export const olderThanTranslationGenerator = variable =>
- n__(
- '%d day until tags are automatically removed',
- '%d days until tags are automatically removed',
- variable,
- );
+export const olderThanTranslationGenerator = variable => n__('%d day', '%d days', variable);
export const keepNTranslationGenerator = variable =>
n__('%d tag per image name', '%d tags per image name', variable);
diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
deleted file mode 100644
index 2b8e9f6ff64..00000000000
--- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
+++ /dev/null
@@ -1,258 +0,0 @@
-<script>
-import { uniqueId } from 'lodash';
-import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlSprintf } from '@gitlab/ui';
-import {
- NAME_REGEX_LENGTH,
- ENABLED_TEXT,
- DISABLED_TEXT,
- TEXT_AREA_INVALID_FEEDBACK,
- EXPIRATION_INTERVAL_LABEL,
- EXPIRATION_SCHEDULE_LABEL,
- KEEP_N_LABEL,
- NAME_REGEX_LABEL,
- NAME_REGEX_PLACEHOLDER,
- NAME_REGEX_DESCRIPTION,
- NAME_REGEX_KEEP_LABEL,
- NAME_REGEX_KEEP_PLACEHOLDER,
- NAME_REGEX_KEEP_DESCRIPTION,
- ENABLE_TOGGLE_LABEL,
- ENABLE_TOGGLE_DESCRIPTION,
-} from '../constants';
-import { mapComputedToEvent } from '../utils';
-
-export default {
- components: {
- GlFormGroup,
- GlToggle,
- GlFormSelect,
- GlFormTextarea,
- GlSprintf,
- },
- props: {
- formOptions: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- apiErrors: {
- type: Object,
- required: false,
- default: null,
- },
- isLoading: {
- type: Boolean,
- required: false,
- default: false,
- },
- value: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- labelCols: {
- type: [Number, String],
- required: false,
- default: 3,
- },
- labelAlign: {
- type: String,
- required: false,
- default: 'right',
- },
- },
- i18n: {
- ENABLE_TOGGLE_LABEL,
- ENABLE_TOGGLE_DESCRIPTION,
- },
- selectList: [
- {
- name: 'expiration-policy-interval',
- label: EXPIRATION_INTERVAL_LABEL,
- model: 'olderThan',
- },
- {
- name: 'expiration-policy-schedule',
- label: EXPIRATION_SCHEDULE_LABEL,
- model: 'cadence',
- },
- {
- name: 'expiration-policy-latest',
- label: KEEP_N_LABEL,
- model: 'keepN',
- },
- ],
- textAreaList: [
- {
- name: 'expiration-policy-name-matching',
- label: NAME_REGEX_LABEL,
- model: 'nameRegex',
- placeholder: NAME_REGEX_PLACEHOLDER,
- description: NAME_REGEX_DESCRIPTION,
- },
- {
- name: 'expiration-policy-keep-name',
- label: NAME_REGEX_KEEP_LABEL,
- model: 'nameRegexKeep',
- placeholder: NAME_REGEX_KEEP_PLACEHOLDER,
- description: NAME_REGEX_KEEP_DESCRIPTION,
- },
- ],
- data() {
- return {
- uniqueId: uniqueId(),
- };
- },
- computed: {
- ...mapComputedToEvent(
- ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'],
- 'value',
- ),
- policyEnabledText() {
- return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
- },
- textAreaValidation() {
- const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex);
- const nameKeepRegexErrors =
- this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep);
-
- return {
- /*
- * The state has this form:
- * null: gray border, no message
- * true: green border, no message ( because none is configured)
- * false: red border, error message
- * So in this function we keep null if the are no message otherwise we 'invert' the error message
- */
- nameRegex: {
- state: nameRegexErrors === null ? null : !nameRegexErrors,
- message: nameRegexErrors,
- },
- nameRegexKeep: {
- state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors,
- message: nameKeepRegexErrors,
- },
- };
- },
- fieldsValidity() {
- return (
- this.textAreaValidation.nameRegex.state !== false &&
- this.textAreaValidation.nameRegexKeep.state !== false
- );
- },
- isFormElementDisabled() {
- return !this.enabled || this.isLoading;
- },
- },
- watch: {
- fieldsValidity: {
- immediate: true,
- handler(valid) {
- if (valid) {
- this.$emit('validated');
- } else {
- this.$emit('invalidated');
- }
- },
- },
- },
- methods: {
- validateRegexLength(value) {
- if (!value) {
- return null;
- }
- return value.length <= NAME_REGEX_LENGTH ? '' : TEXT_AREA_INVALID_FEEDBACK;
- },
- idGenerator(id) {
- return `${id}_${this.uniqueId}`;
- },
- updateModel(value, key) {
- this[key] = value;
- },
- },
-};
-</script>
-
-<template>
- <div ref="form-elements" class="gl-line-height-20">
- <gl-form-group
- :id="idGenerator('expiration-policy-toggle-group')"
- :label-cols="labelCols"
- :label-align="labelAlign"
- :label-for="idGenerator('expiration-policy-toggle')"
- :label="$options.i18n.ENABLE_TOGGLE_LABEL"
- >
- <div class="gl-display-flex">
- <gl-toggle
- :id="idGenerator('expiration-policy-toggle')"
- v-model="enabled"
- :disabled="isLoading"
- />
- <span class="gl-mb-3 gl-ml-3 gl-line-height-20">
- <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION">
- <template #toggleStatus>
- <strong>{{ policyEnabledText }}</strong>
- </template>
- </gl-sprintf>
- </span>
- </div>
- </gl-form-group>
-
- <gl-form-group
- v-for="select in $options.selectList"
- :id="idGenerator(`${select.name}-group`)"
- :key="select.name"
- :label-cols="labelCols"
- :label-align="labelAlign"
- :label-for="idGenerator(select.name)"
- :label="select.label"
- >
- <gl-form-select
- :id="idGenerator(select.name)"
- :value="value[select.model]"
- :disabled="isFormElementDisabled"
- @input="updateModel($event, select.model)"
- >
- <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key">
- {{ option.label }}
- </option>
- </gl-form-select>
- </gl-form-group>
-
- <gl-form-group
- v-for="textarea in $options.textAreaList"
- :id="idGenerator(`${textarea.name}-group`)"
- :key="textarea.name"
- :label-cols="labelCols"
- :label-align="labelAlign"
- :label-for="idGenerator(textarea.name)"
- :state="textAreaValidation[textarea.model].state"
- :invalid-feedback="textAreaValidation[textarea.model].message"
- >
- <template #label>
- <gl-sprintf :message="textarea.label">
- <template #italic="{content}">
- <i>{{ content }}</i>
- </template>
- </gl-sprintf>
- </template>
- <gl-form-textarea
- :id="idGenerator(textarea.name)"
- :value="value[textarea.model]"
- :placeholder="textarea.placeholder"
- :state="textAreaValidation[textarea.model].state"
- :disabled="isFormElementDisabled"
- trim
- @input="updateModel($event, textarea.model)"
- />
- <template #description>
- <span ref="regex-description">
- <gl-sprintf :message="textarea.description">
- <template #code="{content}">
- <code>{{ content }}</code>
- </template>
- </gl-sprintf>
- </span>
- </template>
- </gl-form-group>
- </div>
-</template>
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
deleted file mode 100644
index d1e3d93938b..00000000000
--- a/app/assets/javascripts/registry/shared/constants.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import { s__, __ } from '~/locale';
-
-export const FETCH_SETTINGS_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while fetching the cleanup policy.',
-);
-
-export const UPDATE_SETTINGS_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while updating the cleanup policy.',
-);
-
-export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__(
- 'ContainerRegistry|Cleanup policy successfully saved.',
-);
-
-export const NAME_REGEX_LENGTH = 255;
-
-export const ENABLED_TEXT = __('Enabled');
-export const DISABLED_TEXT = __('Disabled');
-
-export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Cleanup policy:');
-export const ENABLE_TOGGLE_DESCRIPTION = s__(
- 'ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion',
-);
-
-export const TEXT_AREA_INVALID_FEEDBACK = s__(
- 'ContainerRegistry|The value of this input should be less than 256 characters',
-);
-
-export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:');
-export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Expiration schedule:');
-export const KEEP_N_LABEL = s__('ContainerRegistry|Number of tags to retain:');
-export const NAME_REGEX_LABEL = s__(
- 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}',
-);
-export const NAME_REGEX_PLACEHOLDER = '';
-export const NAME_REGEX_DESCRIPTION = s__(
- 'ContainerRegistry|Wildcards such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
-);
-export const NAME_REGEX_KEEP_LABEL = s__(
- 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}',
-);
-export const NAME_REGEX_KEEP_PLACEHOLDER = '';
-export const NAME_REGEX_KEEP_DESCRIPTION = s__(
- 'ContainerRegistry|Wildcards such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
-);
-
-export const KEEP_N_OPTIONS = [
- { variable: 1, key: 'ONE_TAG', default: false },
- { variable: 5, key: 'FIVE_TAGS', default: false },
- { variable: 10, key: 'TEN_TAGS', default: true },
- { variable: 25, key: 'TWENTY_FIVE_TAGS', default: false },
- { variable: 50, key: 'FIFTY_TAGS', default: false },
- { variable: 100, key: 'ONE_HUNDRED_TAGS', default: false },
-];
-
-export const CADENCE_OPTIONS = [
- { key: 'EVERY_DAY', label: __('Every day'), default: true },
- { key: 'EVERY_WEEK', label: __('Every week'), default: false },
- { key: 'EVERY_TWO_WEEKS', label: __('Every two weeks'), default: false },
- { key: 'EVERY_MONTH', label: __('Every month'), default: false },
- { key: 'EVERY_THREE_MONTHS', label: __('Every three months'), default: false },
-];
-
-export const OLDER_THAN_OPTIONS = [
- { key: 'SEVEN_DAYS', variable: 7, default: false },
- { key: 'FOURTEEN_DAYS', variable: 14, default: false },
- { key: 'THIRTY_DAYS', variable: 30, default: false },
- { key: 'NINETY_DAYS', variable: 90, default: true },
-];
diff --git a/app/assets/javascripts/related_issues/components/issue_token.vue b/app/assets/javascripts/related_issues/components/issue_token.vue
index 7f12c10f6a1..9665ed173b9 100644
--- a/app/assets/javascripts/related_issues/components/issue_token.vue
+++ b/app/assets/javascripts/related_issues/components/issue_token.vue
@@ -114,7 +114,7 @@ export default {
class="js-issue-token-remove-button"
@click="onRemoveRequest"
>
- <gl-icon name="close" aria-hidden="true" />
+ <gl-icon name="close" />
</button>
</div>
</template>
diff --git a/app/assets/javascripts/related_issues/components/related_issuable_input.vue b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
index 9809b228308..b05a873e939 100644
--- a/app/assets/javascripts/related_issues/components/related_issuable_input.vue
+++ b/app/assets/javascripts/related_issues/components/related_issuable_input.vue
@@ -97,7 +97,9 @@ export default {
},
beforeDestroy() {
const $input = $(this.$refs.input);
+ // eslint-disable-next-line @gitlab/no-global-event-off
$input.off('shown-issues.atwho');
+ // eslint-disable-next-line @gitlab/no-global-event-off
$input.off('hidden-issues.atwho');
$input.off('inserted-issues.atwho', this.onInput);
},
diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue
index 6f68b25b6fb..73ea13ddc40 100644
--- a/app/assets/javascripts/related_issues/components/related_issues_root.vue
+++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue
@@ -204,7 +204,16 @@ export default {
onInput({ untouchedRawReferences, touchedReference }) {
this.store.addPendingReferences(untouchedRawReferences);
- this.inputValue = `${touchedReference}`;
+ this.formatInput(touchedReference);
+ },
+ formatInput(touchedReference = '') {
+ const startsWithNumber = String(touchedReference).match(/^[0-9]/) !== null;
+
+ if (startsWithNumber) {
+ this.inputValue = `#${touchedReference}`;
+ } else {
+ this.inputValue = `${touchedReference}`;
+ }
},
onBlur(newValue) {
this.processAllReferences(newValue);
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index f245e2bfd2f..0e9975ea81f 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -3,7 +3,7 @@ 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 IssuesList from './issues_list.vue';
-import { status } from '../constants';
+import { status, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '../constants';
export default {
name: 'ReportSection',
@@ -152,12 +152,12 @@ export default {
},
slotName() {
if (this.isSuccess) {
- return 'success';
+ return SLOT_SUCCESS;
} else if (this.isLoading) {
- return 'loading';
+ return SLOT_LOADING;
}
- return 'error';
+ return SLOT_ERROR;
},
},
methods: {
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
index 5e9a5b03543..69b0dcf881d 100644
--- a/app/assets/javascripts/reports/components/test_issue_body.vue
+++ b/app/assets/javascripts/reports/components/test_issue_body.vue
@@ -1,13 +1,13 @@
<script>
import { mapActions } from 'vuex';
-import { GlBadge } from '@gitlab/ui';
-import { n__ } from '~/locale';
+import { GlBadge, GlSprintf } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'TestIssueBody',
components: {
GlBadge,
+ GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -28,18 +28,15 @@ export default {
},
computed: {
showRecentFailures() {
- return this.glFeatures.testFailureHistory && this.issue.recent_failures;
+ return (
+ this.glFeatures.testFailureHistory &&
+ this.issue.recent_failures?.count &&
+ this.issue.recent_failures?.base_branch
+ );
},
},
methods: {
...mapActions(['openModal']),
- recentFailuresText(count) {
- return n__(
- 'Failed %d time in the last 14 days',
- 'Failed %d times in the last 14 days',
- count,
- );
- },
},
};
</script>
@@ -53,7 +50,18 @@ export default {
>
<gl-badge v-if="isNew" variant="danger" class="gl-mr-2">{{ s__('New') }}</gl-badge>
<gl-badge v-if="showRecentFailures" variant="warning" class="gl-mr-2">
- {{ recentFailuresText(issue.recent_failures) }}
+ <gl-sprintf
+ :message="
+ 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',
+ issue.recent_failures.count,
+ )
+ "
+ >
+ <template #count>{{ issue.recent_failures.count }}</template>
+ <template #base_branch>{{ issue.recent_failures.base_branch }}</template>
+ </gl-sprintf>
</gl-badge>
{{ issue.name }}
</button>
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index b3905cbfcfb..9250bfd7678 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -18,10 +18,18 @@ export const ICON_SUCCESS = 'success';
export const ICON_NOTFOUND = 'notfound';
export const status = {
- LOADING: 'LOADING',
- ERROR: 'ERROR',
- SUCCESS: 'SUCCESS',
+ LOADING,
+ ERROR,
+ SUCCESS,
};
export const ACCESSIBILITY_ISSUE_ERROR = 'error';
export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
+
+/**
+ * Slot names for the ReportSection component, corresponding to the success,
+ * loading and error statuses.
+ */
+export const SLOT_SUCCESS = 'success';
+export const SLOT_LOADING = 'loading';
+export const SLOT_ERROR = 'error';
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
index fd6f4933cfa..2d32daee9d0 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -62,12 +62,8 @@ export const recentFailuresTextBuilder = (summary = {}) => {
}
return sprintf(
n__(
- s__(
- 'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days',
- ),
- s__(
- 'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days',
- ),
+ 'Reports|%{recentlyFailed} out of %{failed} failed tests has failed more than once in the last 14 days',
+ 'Reports|%{recentlyFailed} out of %{failed} failed tests have failed more than once in the last 14 days',
recentlyFailed,
),
{ recentlyFailed, failed },
@@ -83,7 +79,10 @@ export const countRecentlyFailedTests = subject => {
return (
[report.new_failures, report.existing_failures, report.resolved_failures]
// only count tests which have failed more than once
- .map(failureArray => failureArray.filter(failure => failure.recent_failures > 1).length)
+ .map(
+ failureArray =>
+ failureArray.filter(failure => failure.recent_failures?.count > 1).length,
+ )
.reduce((total, count) => total + count, 0)
);
})
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index c9c5aa37645..e2c3f3b81ee 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -52,7 +52,7 @@ export default {
<article class="file-holder limited-width-container readme-holder">
<div class="js-file-title file-title-flex-parent">
<div class="file-header-content">
- <gl-icon name="doc-text" aria-hidden="true" />
+ <gl-icon name="doc-text" />
<gl-link :href="blob.webPath">
<strong>{{ blob.name }}</strong>
</gl-link>
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 87c8aa541d8..6f43f837374 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -23,8 +23,11 @@ Sidebar.initialize = function() {
Sidebar.prototype.removeListeners = function() {
this.sidebar.off('click', '.sidebar-collapsed-icon');
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.sidebar.off('hidden.gl.dropdown');
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.dropdown').off('loading.gl.dropdown');
+ // eslint-disable-next-line @gitlab/no-global-event-off
$('.dropdown').off('loaded.gl.dropdown');
$(document).off('click', '.js-sidebar-toggle');
};
diff --git a/app/assets/javascripts/search/group_filter/components/group_filter.vue b/app/assets/javascripts/search/group_filter/components/group_filter.vue
deleted file mode 100644
index 4b7963c5187..00000000000
--- a/app/assets/javascripts/search/group_filter/components/group_filter.vue
+++ /dev/null
@@ -1,124 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownItem,
- GlSearchBoxByType,
- GlLoadingIcon,
- GlIcon,
- GlSkeletonLoader,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { isEmpty } from 'lodash';
-import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
-import { ANY_GROUP, GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM } from '../constants';
-
-export default {
- name: 'GroupFilter',
- components: {
- GlDropdown,
- GlDropdownItem,
- GlSearchBoxByType,
- GlLoadingIcon,
- GlIcon,
- GlSkeletonLoader,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- initialGroup: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- },
- data() {
- return {
- groupSearch: '',
- };
- },
- computed: {
- ...mapState(['groups', 'fetchingGroups']),
- selectedGroup: {
- get() {
- return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup;
- },
- set(group) {
- visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null }));
- },
- },
- },
- methods: {
- ...mapActions(['fetchGroups']),
- isGroupSelected(group) {
- return group.id === this.selectedGroup.id;
- },
- handleGroupChange(group) {
- this.selectedGroup = group;
- },
- },
- ANY_GROUP,
-};
-</script>
-
-<template>
- <gl-dropdown
- ref="groupFilter"
- class="gl-w-full"
- menu-class="gl-w-full!"
- toggle-class="gl-text-truncate gl-reset-line-height!"
- :header-text="__('Filter results by group')"
- @show="fetchGroups(groupSearch)"
- >
- <template #button-content>
- <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
- {{ selectedGroup.name }}
- </span>
- <gl-loading-icon v-if="fetchingGroups" inline class="mr-2" />
- <gl-icon
- v-if="!isGroupSelected($options.ANY_GROUP)"
- v-gl-tooltip
- name="clear"
- :title="__('Clear')"
- class="gl-text-gray-200! gl-hover-text-blue-800!"
- @click.stop="handleGroupChange($options.ANY_GROUP)"
- />
- <gl-icon name="chevron-down" />
- </template>
- <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white">
- <gl-search-box-by-type
- v-model="groupSearch"
- class="m-2"
- :debounce="500"
- @input="fetchGroups"
- />
- <gl-dropdown-item
- class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
- :is-check-item="true"
- :is-checked="isGroupSelected($options.ANY_GROUP)"
- @click="handleGroupChange($options.ANY_GROUP)"
- >
- {{ $options.ANY_GROUP.name }}
- </gl-dropdown-item>
- </div>
- <div v-if="!fetchingGroups">
- <gl-dropdown-item
- v-for="group in groups"
- :key="group.id"
- :is-check-item="true"
- :is-checked="isGroupSelected(group)"
- @click="handleGroupChange(group)"
- >
- {{ group.full_name }}
- </gl-dropdown-item>
- </div>
- <div v-if="fetchingGroups" class="mx-3 mt-2">
- <gl-skeleton-loader :height="100">
- <rect y="0" width="90%" height="20" rx="4" />
- <rect y="40" width="70%" height="20" rx="4" />
- <rect y="80" width="80%" height="20" rx="4" />
- </gl-skeleton-loader>
- </div>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/search/group_filter/constants.js b/app/assets/javascripts/search/group_filter/constants.js
deleted file mode 100644
index 9bd92eaa130..00000000000
--- a/app/assets/javascripts/search/group_filter/constants.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { __ } from '~/locale';
-
-export const ANY_GROUP = Object.freeze({
- id: null,
- name: __('Any'),
-});
-
-export const GROUP_QUERY_PARAM = 'group_id';
-
-export const PROJECT_QUERY_PARAM = 'project_id';
diff --git a/app/assets/javascripts/search/group_filter/index.js b/app/assets/javascripts/search/group_filter/index.js
deleted file mode 100644
index 9b009bc0305..00000000000
--- a/app/assets/javascripts/search/group_filter/index.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import GroupFilter from './components/group_filter.vue';
-
-Vue.use(Translate);
-
-export default store => {
- let initialGroup;
- const el = document.getElementById('js-search-group-dropdown');
-
- const { initialGroupData } = el.dataset;
-
- initialGroup = JSON.parse(initialGroupData);
- initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true });
-
- return new Vue({
- el,
- store,
- render(createElement) {
- return createElement(GroupFilter, {
- props: {
- initialGroup,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index 781a564d077..d2bb1ccfc44 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -1,7 +1,7 @@
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
+import { initTopbar } from './topbar';
import { initSidebar } from './sidebar';
-import initGroupFilter from './group_filter';
export const initSearchApp = () => {
// Similar to url_utility.decodeUrlParameter
@@ -9,6 +9,6 @@ export const initSearchApp = () => {
const sanitizedSearch = window.location.search.replace(/\+/g, '%20');
const store = createStore({ query: queryToObject(sanitizedSearch) });
+ initTopbar(store);
initSidebar(store);
- initGroupFilter(store);
};
diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue
index aa11b2025f2..e233d18b716 100644
--- a/app/assets/javascripts/search/sidebar/components/app.vue
+++ b/app/assets/javascripts/search/sidebar/components/app.vue
@@ -26,7 +26,7 @@ export default {
<template>
<form
- class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mt-5"
+ class="search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4 gl-mb-6 gl-mt-5"
@submit.prevent="applyQuery"
>
<status-filter />
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index 447278aa223..082beb5930d 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -16,6 +16,28 @@ export const fetchGroups = ({ commit }, search) => {
});
};
+export const fetchProjects = ({ commit, state }, search) => {
+ commit(types.REQUEST_PROJECTS);
+ const groupId = state.query?.group_id;
+ const callback = data => {
+ if (data) {
+ commit(types.RECEIVE_PROJECTS_SUCCESS, data);
+ } else {
+ createFlash({ message: __('There was an error fetching projects') });
+ commit(types.RECEIVE_PROJECTS_ERROR);
+ }
+ };
+
+ if (groupId) {
+ Api.groupProjects(groupId, search, {}, callback);
+ } else {
+ // The .catch() is due to the API method not handling a rejection properly
+ Api.projects(search, { order_by: 'id' }, callback).catch(() => {
+ callback();
+ });
+ }
+};
+
export const setQuery = ({ commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
};
diff --git a/app/assets/javascripts/search/store/mutation_types.js b/app/assets/javascripts/search/store/mutation_types.js
index 2482621d4d7..a6430b53c4f 100644
--- a/app/assets/javascripts/search/store/mutation_types.js
+++ b/app/assets/javascripts/search/store/mutation_types.js
@@ -2,4 +2,8 @@ export const REQUEST_GROUPS = 'REQUEST_GROUPS';
export const RECEIVE_GROUPS_SUCCESS = 'RECEIVE_GROUPS_SUCCESS';
export const RECEIVE_GROUPS_ERROR = 'RECEIVE_GROUPS_ERROR';
+export const REQUEST_PROJECTS = 'REQUEST_PROJECTS';
+export const RECEIVE_PROJECTS_SUCCESS = 'RECEIVE_PROJECTS_SUCCESS';
+export const RECEIVE_PROJECTS_ERROR = 'RECEIVE_PROJECTS_ERROR';
+
export const SET_QUERY = 'SET_QUERY';
diff --git a/app/assets/javascripts/search/store/mutations.js b/app/assets/javascripts/search/store/mutations.js
index e57850b870e..91d7cf66c8f 100644
--- a/app/assets/javascripts/search/store/mutations.js
+++ b/app/assets/javascripts/search/store/mutations.js
@@ -12,6 +12,17 @@ export default {
state.fetchingGroups = false;
state.groups = [];
},
+ [types.REQUEST_PROJECTS](state) {
+ state.fetchingProjects = true;
+ },
+ [types.RECEIVE_PROJECTS_SUCCESS](state, data) {
+ state.fetchingProjects = false;
+ state.projects = data;
+ },
+ [types.RECEIVE_PROJECTS_ERROR](state) {
+ state.fetchingProjects = false;
+ state.projects = [];
+ },
[types.SET_QUERY](state, { key, value }) {
state.query[key] = value;
},
diff --git a/app/assets/javascripts/search/store/state.js b/app/assets/javascripts/search/store/state.js
index 70a8aab9998..9a0d61d0b93 100644
--- a/app/assets/javascripts/search/store/state.js
+++ b/app/assets/javascripts/search/store/state.js
@@ -2,5 +2,7 @@ const createState = ({ query }) => ({
query,
groups: [],
fetchingGroups: false,
+ projects: [],
+ fetchingProjects: false,
});
export default createState;
diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue
new file mode 100644
index 00000000000..fce9ec17d23
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/components/group_filter.vue
@@ -0,0 +1,49 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { isEmpty } from 'lodash';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import SearchableDropdown from './searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
+
+export default {
+ name: 'GroupFilter',
+ components: {
+ SearchableDropdown,
+ },
+ props: {
+ initialData: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ ...mapState(['groups', 'fetchingGroups']),
+ selectedGroup() {
+ return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
+ },
+ },
+ methods: {
+ ...mapActions(['fetchGroups']),
+ handleGroupChange(group) {
+ visitUrl(
+ setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }),
+ );
+ },
+ },
+ GROUP_DATA,
+};
+</script>
+
+<template>
+ <searchable-dropdown
+ :header-text="$options.GROUP_DATA.headerText"
+ :selected-display-value="$options.GROUP_DATA.selectedDisplayValue"
+ :items-display-value="$options.GROUP_DATA.itemsDisplayValue"
+ :loading="fetchingGroups"
+ :selected-item="selectedGroup"
+ :items="groups"
+ @search="fetchGroups"
+ @change="handleGroupChange"
+ />
+</template>
diff --git a/app/assets/javascripts/search/topbar/components/project_filter.vue b/app/assets/javascripts/search/topbar/components/project_filter.vue
new file mode 100644
index 00000000000..3f1f3848ac7
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/components/project_filter.vue
@@ -0,0 +1,52 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import SearchableDropdown from './searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
+
+export default {
+ name: 'ProjectFilter',
+ components: {
+ SearchableDropdown,
+ },
+ props: {
+ initialData: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ },
+ computed: {
+ ...mapState(['projects', 'fetchingProjects']),
+ selectedProject() {
+ return this.initialData ? this.initialData : ANY_OPTION;
+ },
+ },
+ methods: {
+ ...mapActions(['fetchProjects']),
+ handleProjectChange(project) {
+ // This determines if we need to update the group filter or not
+ const queryParams = {
+ ...(project.namespace_id && { [GROUP_DATA.queryParam]: project.namespace_id }),
+ [PROJECT_DATA.queryParam]: project.id,
+ };
+
+ visitUrl(setUrlParams(queryParams));
+ },
+ },
+ PROJECT_DATA,
+};
+</script>
+
+<template>
+ <searchable-dropdown
+ :header-text="$options.PROJECT_DATA.headerText"
+ :selected-display-value="$options.PROJECT_DATA.selectedDisplayValue"
+ :items-display-value="$options.PROJECT_DATA.itemsDisplayValue"
+ :loading="fetchingProjects"
+ :selected-item="selectedProject"
+ :items="projects"
+ @search="fetchProjects"
+ @change="handleProjectChange"
+ />
+</template>
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
new file mode 100644
index 00000000000..14577fd7d7a
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
@@ -0,0 +1,144 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlIcon,
+ GlButton,
+ GlSkeletonLoader,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+import { ANY_OPTION } from '../constants';
+
+export default {
+ name: 'SearchableDropdown',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlIcon,
+ GlButton,
+ GlSkeletonLoader,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ headerText: {
+ type: String,
+ required: false,
+ default: "__('Filter')",
+ },
+ selectedDisplayValue: {
+ type: String,
+ required: false,
+ default: 'name',
+ },
+ itemsDisplayValue: {
+ type: String,
+ required: false,
+ default: 'name',
+ },
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectedItem: {
+ type: Object,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ searchText: '',
+ };
+ },
+ methods: {
+ isSelected(selected) {
+ return selected.id === this.selectedItem.id;
+ },
+ openDropdown() {
+ this.$emit('search', this.searchText);
+ },
+ resetDropdown() {
+ this.$emit('change', ANY_OPTION);
+ },
+ },
+ ANY_OPTION,
+};
+</script>
+
+<template>
+ <gl-dropdown
+ class="gl-w-full"
+ menu-class="gl-w-full!"
+ toggle-class="gl-text-truncate"
+ :header-text="headerText"
+ @show="$emit('search', searchText)"
+ @shown="$refs.searchBox.focusInput()"
+ >
+ <template #button-content>
+ <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
+ {{ selectedItem[selectedDisplayValue] }}
+ </span>
+ <gl-loading-icon v-if="loading" inline class="gl-mr-3" />
+ <gl-button
+ v-if="!isSelected($options.ANY_OPTION)"
+ v-gl-tooltip
+ name="clear"
+ category="tertiary"
+ :title="__('Clear')"
+ class="gl-p-0! gl-mr-2"
+ @keydown.enter.stop="resetDropdown"
+ @click.stop="resetDropdown"
+ >
+ <gl-icon name="clear" class="gl-text-gray-200! gl-hover-text-blue-800!" />
+ </gl-button>
+ <gl-icon name="chevron-down" />
+ </template>
+ <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white">
+ <gl-search-box-by-type
+ ref="searchBox"
+ v-model="searchText"
+ class="gl-m-3"
+ :debounce="500"
+ @input="$emit('search', searchText)"
+ />
+ <gl-dropdown-item
+ class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
+ :is-check-item="true"
+ :is-checked="isSelected($options.ANY_OPTION)"
+ @click="resetDropdown"
+ >
+ {{ $options.ANY_OPTION.name }}
+ </gl-dropdown-item>
+ </div>
+ <div v-if="!loading">
+ <gl-dropdown-item
+ v-for="item in items"
+ :key="item.id"
+ :is-check-item="true"
+ :is-checked="isSelected(item)"
+ @click="$emit('change', item)"
+ >
+ {{ item[itemsDisplayValue] }}
+ </gl-dropdown-item>
+ </div>
+ <div v-if="loading" class="gl-mx-4 gl-mt-3">
+ <gl-skeleton-loader :height="100">
+ <rect y="0" width="90%" height="20" rx="4" />
+ <rect y="40" width="70%" height="20" rx="4" />
+ <rect y="80" width="80%" height="20" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js
new file mode 100644
index 00000000000..3944b2c8374
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/constants.js
@@ -0,0 +1,21 @@
+import { __ } from '~/locale';
+
+export const ANY_OPTION = Object.freeze({
+ id: null,
+ name: __('Any'),
+ name_with_namespace: __('Any'),
+});
+
+export const GROUP_DATA = {
+ headerText: __('Filter results by group'),
+ queryParam: 'group_id',
+ selectedDisplayValue: 'name',
+ itemsDisplayValue: 'full_name',
+};
+
+export const PROJECT_DATA = {
+ headerText: __('Filter results by project'),
+ queryParam: 'project_id',
+ selectedDisplayValue: 'name_with_namespace',
+ itemsDisplayValue: 'name_with_namespace',
+};
diff --git a/app/assets/javascripts/search/topbar/index.js b/app/assets/javascripts/search/topbar/index.js
new file mode 100644
index 00000000000..024544148a0
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/index.js
@@ -0,0 +1,44 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import GroupFilter from './components/group_filter.vue';
+import ProjectFilter from './components/project_filter.vue';
+
+Vue.use(Translate);
+
+const mountSearchableDropdown = (store, { id, component }) => {
+ const el = document.getElementById(id);
+
+ if (!el) {
+ return false;
+ }
+
+ let { initialData } = el.dataset;
+
+ initialData = JSON.parse(initialData);
+
+ return new Vue({
+ el,
+ store,
+ render(createElement) {
+ return createElement(component, {
+ props: {
+ initialData,
+ },
+ });
+ },
+ });
+};
+
+const searchableDropdowns = [
+ {
+ id: 'js-search-group-dropdown',
+ component: GroupFilter,
+ },
+ {
+ id: 'js-search-project-dropdown',
+ component: ProjectFilter,
+ },
+];
+
+export const initTopbar = store =>
+ searchableDropdowns.map(dropdown => mountSearchableDropdown(store, dropdown));
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 7073b9ca12d..97674348436 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -250,6 +250,10 @@ export class SearchAutocomplete {
url: `${mrPath}/?assignee_username=${userName}`,
},
{
+ text: s__("SearchAutocomplete|Merge requests that I'm a reviewer"),
+ url: `${mrPath}/?reviewer_username=${userName}`,
+ },
+ {
text: s__("SearchAutocomplete|Merge requests I've created"),
url: `${mrPath}/?author_username=${userName}`,
},
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 30e4e92d0cc..f2685dfbcdb 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,8 +1,9 @@
<script>
/* eslint-disable vue/no-v-html */
import $ from 'jquery';
+import Vue from 'vue';
import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
-import { GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui';
+import { GlToast, GlModal, GlTooltipDirective, GlIcon, GlFormCheckbox } from '@gitlab/ui';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { __, s__ } from '~/locale';
import Api from '~/api';
@@ -16,6 +17,8 @@ export const AVAILABILITY_STATUS = {
NOT_SET: 'not_set',
};
+Vue.use(GlToast);
+
export default {
components: {
GlIcon,
diff --git a/app/assets/javascripts/settings_panels.js b/app/assets/javascripts/settings_panels.js
index d22aca35e09..18160421136 100644
--- a/app/assets/javascripts/settings_panels.js
+++ b/app/assets/javascripts/settings_panels.js
@@ -3,6 +3,7 @@ import { __ } from './locale';
function expandSection($section) {
$section.find('.js-settings-toggle:not(.js-settings-toggle-trigger-only)').text(__('Collapse'));
+ // eslint-disable-next-line @gitlab/no-global-event-off
$section
.find('.settings-content')
.off('scroll.expandSection')
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
index 00f1339d7f2..da9ff407faf 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -1,7 +1,11 @@
<script>
+import { GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
+ components: {
+ GlIcon,
+ },
props: {
user: {
type: Object,
@@ -46,6 +50,6 @@ export default {
class="avatar avatar-inline m-0"
data-qa-selector="avatar_image"
/>
- <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i>
+ <gl-icon v-if="hasMergeIcon" name="warning-solid" aria-hidden="true" class="merge-icon" />
</span>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index 5f8ba844218..26e88523abb 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -66,7 +66,7 @@ export default {
href="#"
role="button"
>
- <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" />
+ <gl-icon data-hidden="true" name="chevron-double-lg-right" :size="12" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
index eabd4d88d52..362ca4ab917 100644
--- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -112,11 +112,12 @@ export default {
/>
<button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button">
<span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span>
- <i
+ <gl-icon
v-if="isMergeRequest && !allAssigneesCanMerge"
+ name="warning-solid"
aria-hidden="true"
- class="fa fa-exclamation-triangle merge-icon"
- ></i>
+ class="merge-icon"
+ />
</button>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
index cf6a0a4a151..3c1b3afe889 100644
--- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue
@@ -1,9 +1,11 @@
<script>
+import { GlButton } from '@gitlab/ui';
import { n__ } from '~/locale';
import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue';
export default {
components: {
+ GlButton,
UncollapsedAssigneeList,
},
inject: ['rootPath'],
@@ -27,9 +29,15 @@ export default {
<template>
<div class="gl-display-flex gl-flex-direction-column">
<div v-if="emptyUsers" data-testid="none">
- <span>
- {{ __('None') }}
- </span>
+ <span> {{ __('None') }} -</span>
+ <gl-button
+ data-testid="assign-yourself"
+ category="tertiary"
+ variant="link"
+ @click="$emit('assign-self')"
+ >
+ <span class="gl-text-gray-400">{{ __('assign yourself') }}</span>
+ </gl-button>
</div>
<uncollapsed-assignee-list v-else :users="users" :root-path="rootPath" />
</div>
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index 2530cb77acd..ce120ff82f3 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -77,7 +77,7 @@ export default {
class="sidebar-collapsed-icon"
@click="toggleForm"
>
- <gl-icon :name="confidentialityIcon" aria-hidden="true" />
+ <gl-icon :name="confidentialityIcon" />
</div>
<div class="title hide-collapsed">
{{ __('Confidentiality') }}
@@ -101,16 +101,11 @@ export default {
:issuable-type="issuableType"
/>
<div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential">
- <gl-icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" />
+ <gl-icon :size="16" name="eye" class="sidebar-item-icon inline" />
{{ __('Not confidential') }}
</div>
<div v-else class="value sidebar-item-value hide-collapsed">
- <gl-icon
- :size="16"
- name="eye-slash"
- aria-hidden="true"
- class="sidebar-item-icon inline is-active"
- />
+ <gl-icon :size="16" name="eye-slash" class="sidebar-item-icon inline is-active" />
{{ confidentialText }}
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index 1785174e8d7..07abfa8d103 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -1,7 +1,7 @@
<script>
import $ from 'jquery';
import { camelCase, difference, union } from 'lodash';
-import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql';
+import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
index 45707c18f7b..10b16a44261 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/collapsed_reviewer_list.vue
@@ -97,11 +97,12 @@ export default {
<collapsed-reviewer v-for="user in collapsedUsers" :key="user.id" :user="user" />
<button v-if="hasMoreThanTwoReviewers" class="btn-link" type="button">
<span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span>
- <i
+ <gl-icon
v-if="!allReviewersCanMerge"
+ name="warning-solid"
aria-hidden="true"
- class="fa fa-exclamation-triangle merge-icon"
- ></i>
+ class="merge-icon"
+ />
</button>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
index 9fa3fa38eac..7961b7cd679 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue
@@ -1,9 +1,13 @@
<script>
// NOTE! For the first iteration, we are simply copying the implementation of Assignees
// It will soon be overhauled in Issue https://gitlab.com/gitlab-org/gitlab/-/issues/233736
+import { GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
export default {
+ components: {
+ GlIcon,
+ },
props: {
user: {
type: Object,
@@ -38,6 +42,6 @@ export default {
class="avatar avatar-inline m-0"
data-qa-selector="avatar_image"
/>
- <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i>
+ <gl-icon v-if="hasMergeIcon" name="warning-solid" aria-hidden="true" class="merge-icon" />
</span>
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
index 4f4f7002dc9..d64b483acb1 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
@@ -59,7 +59,7 @@ export default {
href="#"
role="button"
>
- <gl-icon aria-hidden="true" data-hidden="true" name="chevron-double-lg-right" :size="12" />
+ <gl-icon data-hidden="true" name="chevron-double-lg-right" :size="12" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index 6e004084077..6d21936791c 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -114,12 +114,7 @@ export default {
class="sidebar-collapsed-icon"
@click="onClickCollapsedIcon"
>
- <gl-icon
- :name="notificationIcon"
- :size="16"
- aria-hidden="true"
- class="sidebar-item-icon is-active"
- />
+ <gl-icon :name="notificationIcon" :size="16" class="sidebar-item-icon is-active" />
</span>
<span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span>
<toggle-button
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index 51719df313f..1e3e870ec83 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -1,19 +1,18 @@
<script>
-import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
const MARK_TEXT = __('Mark as done');
const TODO_TEXT = __('Add a To-Do');
export default {
- directives: {
- tooltip,
- },
components: {
GlIcon,
GlLoadingIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
issuableId: {
type: Number,
@@ -71,16 +70,13 @@ export default {
<template>
<button
- v-tooltip
+ v-gl-tooltip.left.viewport
:class="buttonClasses"
:title="buttonTooltip"
:aria-label="buttonLabel"
:data-issuable-id="issuableId"
:data-issuable-type="issuableType"
type="button"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
@click="handleButtonClick"
>
<gl-icon
diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js
index 3492f19c996..f751df6367e 100644
--- a/app/assets/javascripts/single_file_diff.js
+++ b/app/assets/javascripts/single_file_diff.js
@@ -23,7 +23,8 @@ export default class SingleFileDiff {
this.file = file;
this.toggleDiff = this.toggleDiff.bind(this);
this.content = $('.diff-content', this.file);
- this.$toggleIcon = $('.diff-toggle-caret', this.file);
+ this.$chevronRightIcon = $('.diff-toggle-caret .chevron-right', this.file);
+ this.$chevronDownIcon = $('.diff-toggle-caret .chevron-down', this.file);
this.diffForPath = this.content.find('[data-diff-for-path]').data('diffForPath');
this.isOpen = !this.diffForPath;
if (this.diffForPath) {
@@ -34,13 +35,13 @@ export default class SingleFileDiff {
.hide();
this.content = null;
this.collapsedContent.after(this.loadingContent);
- this.$toggleIcon.addClass('fa-caret-right');
+ this.$chevronRightIcon.removeClass('gl-display-none');
} else {
this.collapsedContent = $(WRAPPER)
.html(COLLAPSED_HTML)
.hide();
this.content.after(this.collapsedContent);
- this.$toggleIcon.addClass('fa-caret-down');
+ this.$chevronDownIcon.removeClass('gl-display-none');
}
$('.js-file-title, .click-to-expand', this.file).on('click', e => {
@@ -52,20 +53,23 @@ export default class SingleFileDiff {
if (
!$target.hasClass('js-file-title') &&
!$target.hasClass('click-to-expand') &&
- !$target.hasClass('diff-toggle-caret')
+ !$target.closest('.diff-toggle-caret').length > 0
)
return;
this.isOpen = !this.isOpen;
if (!this.isOpen && !this.hasError) {
this.content.hide();
- this.$toggleIcon.addClass('fa-caret-right').removeClass('fa-caret-down');
+ this.$chevronRightIcon.removeClass('gl-display-none');
+ this.$chevronDownIcon.addClass('gl-display-none');
this.collapsedContent.show();
} else if (this.content) {
this.collapsedContent.hide();
this.content.show();
- this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
+ this.$chevronDownIcon.removeClass('gl-display-none');
+ this.$chevronRightIcon.addClass('gl-display-none');
} else {
- this.$toggleIcon.addClass('fa-caret-down').removeClass('fa-caret-right');
+ this.$chevronDownIcon.removeClass('gl-display-none');
+ this.$chevronRightIcon.addClass('gl-display-none');
return this.getContentHTML(cb);
}
}
diff --git a/app/assets/javascripts/smart_interval.js b/app/assets/javascripts/smart_interval.js
index 0e52d2d8010..c4655d35cf0 100644
--- a/app/assets/javascripts/smart_interval.js
+++ b/app/assets/javascripts/smart_interval.js
@@ -95,6 +95,7 @@ export default class SmartInterval {
window.removeEventListener('blur', this.onWindowVisibilityChange);
window.removeEventListener('focus', this.onWindowVisibilityChange);
this.cancel();
+ // eslint-disable-next-line @gitlab/no-global-event-off
$(document)
.off('visibilitychange')
.off('beforeunload');
diff --git a/app/assets/javascripts/sourcegraph/index.js b/app/assets/javascripts/sourcegraph/index.js
index 796e90bf08e..487a565b152 100644
--- a/app/assets/javascripts/sourcegraph/index.js
+++ b/app/assets/javascripts/sourcegraph/index.js
@@ -17,7 +17,7 @@ export default function initSourcegraph() {
return;
}
- const assetsUrl = new URL('/assets/webpack/sourcegraph/', window.location.href);
+ const assetsUrl = new URL(process.env.SOURCEGRAPH_PUBLIC_PATH, window.location.href);
const scriptPath = new URL('scripts/integration.bundle.js', assetsUrl).href;
window.SOURCEGRAPH_ASSETS_URL = assetsUrl.href;
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue
index 69eabfe5339..b47126cdeb3 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue
@@ -60,6 +60,7 @@ export default {
},
data() {
return {
+ formattedMarkdown: null,
parsedSource: parseSourceFile(this.preProcess(true, this.content)),
editorMode: EDITOR_TYPES.wysiwyg,
hasMatter: false,
@@ -140,10 +141,14 @@ export default {
onSubmit() {
const preProcessedContent = this.preProcess(false, this.parsedSource.content());
this.$emit('submit', {
+ formattedMarkdown: this.formattedMarkdown,
content: preProcessedContent,
images: this.$options.imageRepository.getAll(),
});
},
+ onEditorLoad({ formattedMarkdown }) {
+ this.formattedMarkdown = formattedMarkdown;
+ },
},
};
</script>
@@ -167,6 +172,7 @@ export default {
@modeChange="onModeChange"
@input="onInputChange"
@uploadImage="onUploadImage"
+ @load="onEditorLoad"
/>
<unsaved-changes-confirm-dialog :modified="isSaveable" />
<publish-toolbar
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index faa4026c064..4cabd943e22 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -15,10 +15,21 @@ export const LOAD_CONTENT_ERROR = __(
'An error ocurred while loading your content. Please try again.',
);
+export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__(
+ 'StaticSiteEditor|Automatic formatting changes',
+);
+
+export const DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION = s__(
+ 'StaticSiteEditor|Markdown formatting preferences introduced by the Static Site Editor',
+);
+
export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
export const TRACKING_ACTION_CREATE_MERGE_REQUEST = 'create_merge_request';
export const TRACKING_ACTION_INITIALIZE_EDITOR = 'initialize_editor';
+export const USAGE_PING_TRACKING_ACTION_CREATE_COMMIT = 'static_site_editor_commits';
+export const USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST = 'static_site_editor_merge_requests';
+
export const MR_META_LOCAL_STORAGE_KEY = 'sse-merge-request-meta-storage-key';
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
index 4137ede49c6..1bd79d40071 100644
--- a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
@@ -4,7 +4,17 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
const submitContentChangesResolver = (
_,
- { input: { project: projectId, username, sourcePath, content, images, mergeRequestMeta } },
+ {
+ input: {
+ project: projectId,
+ username,
+ sourcePath,
+ content,
+ images,
+ mergeRequestMeta,
+ formattedMarkdown,
+ },
+ },
{ cache },
) => {
return submitContentChanges({
@@ -14,6 +24,7 @@ const submitContentChangesResolver = (
content,
images,
mergeRequestMeta,
+ formattedMarkdown,
}).then(savedContentMeta => {
const data = produce(savedContentMeta, draftState => {
return {
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
index 68943113c14..1e52e73294e 100644
--- a/app/assets/javascripts/static_site_editor/pages/home.vue
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -53,6 +53,7 @@ export default {
return {
content: null,
images: null,
+ formattedMarkdown: null,
submitChangesError: null,
isSavingChanges: false,
};
@@ -79,9 +80,10 @@ export default {
onDismissError() {
this.submitChangesError = null;
},
- onPrepareSubmit({ content, images }) {
+ onPrepareSubmit({ formattedMarkdown, content, images }) {
this.content = content;
this.images = images;
+ this.formattedMarkdown = formattedMarkdown;
this.isSavingChanges = true;
this.$refs.editMetaModal.show();
@@ -110,6 +112,7 @@ export default {
username: this.appData.username,
sourcePath: this.appData.sourcePath,
content: this.content,
+ formattedMarkdown: this.formattedMarkdown,
images: this.images,
mergeRequestMeta,
},
diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
index 8623a671a7d..e57028ea05a 100644
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
@@ -10,6 +10,10 @@ import {
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
TRACKING_ACTION_CREATE_COMMIT,
TRACKING_ACTION_CREATE_MERGE_REQUEST,
+ USAGE_PING_TRACKING_ACTION_CREATE_COMMIT,
+ USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST,
+ DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE,
+ DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION,
} from '../constants';
const createBranch = (projectId, branch) =>
@@ -45,22 +49,24 @@ const createImageActions = (images, markdown) => {
return actions;
};
-const commitContent = (projectId, message, branch, sourcePath, content, images) => {
+const createUpdateSourceFileAction = (sourcePath, content) => [
+ convertObjectPropsToSnakeCase({
+ action: 'update',
+ filePath: sourcePath,
+ content,
+ }),
+];
+
+const commit = (projectId, message, branch, actions) => {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT);
+ Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_COMMIT);
return Api.commitMultiple(
projectId,
convertObjectPropsToSnakeCase({
branch,
commitMessage: message,
- actions: [
- convertObjectPropsToSnakeCase({
- action: 'update',
- filePath: sourcePath,
- content,
- }),
- ...createImageActions(images, content),
- ],
+ actions,
}),
).catch(() => {
throw new Error(SUBMIT_CHANGES_COMMIT_ERROR);
@@ -75,6 +81,7 @@ const createMergeRequest = (
targetBranch = DEFAULT_TARGET_BRANCH,
) => {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_MERGE_REQUEST);
+ Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST);
return Api.createProjectMergeRequest(
projectId,
@@ -96,6 +103,7 @@ const submitContentChanges = ({
content,
images,
mergeRequestMeta,
+ formattedMarkdown,
}) => {
const branch = generateBranchName(username);
const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta;
@@ -103,10 +111,25 @@ const submitContentChanges = ({
return createBranch(projectId, branch)
.then(({ data: { web_url: url } }) => {
+ const message = `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`;
+
Object.assign(meta, { branch: { label: branch, url } });
- return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images);
+ return formattedMarkdown
+ ? commit(
+ projectId,
+ message,
+ branch,
+ createUpdateSourceFileAction(sourcePath, formattedMarkdown),
+ )
+ : meta;
})
+ .then(() =>
+ commit(projectId, mergeRequestTitle, branch, [
+ ...createUpdateSourceFileAction(sourcePath, content),
+ ...createImageActions(images, content),
+ ]),
+ )
.then(({ data: { short_id: label, web_url: url } }) => {
Object.assign(meta, { commit: { label, url } });
diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js
index cf9064aba57..bae320cb705 100644
--- a/app/assets/javascripts/terminal/terminal.js
+++ b/app/assets/javascripts/terminal/terminal.js
@@ -25,6 +25,7 @@ export default class GLTerminal {
this.setSocketUrl();
this.createTerminal();
+ // eslint-disable-next-line @gitlab/no-global-event-off
$(window)
.off('resize.terminal')
.on('resize.terminal', () => {
@@ -104,6 +105,7 @@ export default class GLTerminal {
}
dispose() {
+ // eslint-disable-next-line @gitlab/no-global-event-off
this.terminal.off('data');
this.terminal.dispose();
this.socket.close();
diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue
index 2e4c18c5a5b..d0d49233334 100644
--- a/app/assets/javascripts/terraform/components/states_table.vue
+++ b/app/assets/javascripts/terraform/components/states_table.vue
@@ -1,16 +1,22 @@
<script>
-import { GlBadge, GlIcon, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
+import { GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
import { s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import StateActions from './states_table_actions.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
components: {
+ CiBadge,
GlBadge,
GlIcon,
+ GlLink,
GlSprintf,
GlTable,
GlTooltip,
+ StateActions,
TimeAgoTooltip,
},
mixins: [timeagoMixin],
@@ -19,28 +25,73 @@ export default {
required: true,
type: Array,
},
+ terraformAdmin: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
},
computed: {
fields() {
- return [
+ const columns = [
{
key: 'name',
- thClass: 'gl-display-none',
+ label: this.$options.i18n.name,
+ },
+ {
+ key: 'pipeline',
+ label: this.$options.i18n.pipeline,
},
{
key: 'updated',
- thClass: 'gl-display-none',
- tdClass: 'gl-text-right',
+ label: this.$options.i18n.details,
},
];
+
+ if (this.terraformAdmin) {
+ columns.push({
+ key: 'actions',
+ label: this.$options.i18n.actions,
+ thClass: 'gl-w-12',
+ tdClass: 'gl-text-right',
+ });
+ }
+
+ return columns;
},
},
+ i18n: {
+ actions: s__('Terraform|Actions'),
+ details: s__('Terraform|Details'),
+ jobStatus: s__('Terraform|Job status'),
+ locked: s__('Terraform|Locked'),
+ lockedByUser: s__('Terraform|Locked by %{user} %{timeAgo}'),
+ name: s__('Terraform|Name'),
+ pipeline: s__('Terraform|Pipeline'),
+ unknownUser: s__('Terraform|Unknown User'),
+ updatedUser: s__('Terraform|%{user} updated %{timeAgo}'),
+ },
methods: {
createdByUserName(item) {
return item.latestVersion?.createdByUser?.name;
},
lockedByUserName(item) {
- return item.lockedByUser?.name || s__('Terraform|Unknown User');
+ return item.lockedByUser?.name || this.$options.i18n.unknownUser;
+ },
+ pipelineDetailedStatus(item) {
+ return item.latestVersion?.job?.detailedStatus;
+ },
+ pipelineID(item) {
+ let id = item.latestVersion?.job?.pipeline?.id;
+
+ if (id) {
+ id = getIdFromGraphQLId(id);
+ }
+
+ return id;
+ },
+ pipelinePath(item) {
+ return item.latestVersion?.job?.pipeline?.path;
},
updatedTime(item) {
return item.latestVersion?.updatedAt || item.updatedAt;
@@ -50,25 +101,34 @@ export default {
</script>
<template>
- <gl-table :items="states" :fields="fields" data-testid="terraform-states-table">
+ <gl-table
+ :items="states"
+ :fields="fields"
+ data-testid="terraform-states-table"
+ fixed
+ stacked="md"
+ >
<template #cell(name)="{ item }">
- <div class="gl-display-flex align-items-center" data-testid="terraform-states-table-name">
+ <div
+ class="gl-display-flex align-items-center gl-justify-content-end gl-justify-content-md-start"
+ data-testid="terraform-states-table-name"
+ >
<p class="gl-font-weight-bold gl-m-0 gl-text-gray-900">
{{ item.name }}
</p>
- <div v-if="item.lockedAt" id="terraformLockedBadgeContainer" class="gl-mx-2">
- <gl-badge id="terraformLockedBadge">
+ <div v-if="item.lockedAt" :id="`terraformLockedBadgeContainer${item.name}`" class="gl-mx-2">
+ <gl-badge :id="`terraformLockedBadge${item.name}`">
<gl-icon name="lock" />
- {{ s__('Terraform|Locked') }}
+ {{ $options.i18n.locked }}
</gl-badge>
<gl-tooltip
- container="terraformLockedBadgeContainer"
+ :container="`terraformLockedBadgeContainer${item.name}`"
+ :target="`terraformLockedBadge${item.name}`"
placement="right"
- target="terraformLockedBadge"
>
- <gl-sprintf :message="s__('Terraform|Locked by %{user} %{timeAgo}')">
+ <gl-sprintf :message="$options.i18n.lockedByUser">
<template #user>
{{ lockedByUserName(item) }}
</template>
@@ -82,9 +142,37 @@ export default {
</div>
</template>
+ <template #cell(pipeline)="{ item }">
+ <div data-testid="terraform-states-table-pipeline" class="gl-min-h-7">
+ <gl-link v-if="pipelineID(item)" :href="pipelinePath(item)">
+ #{{ pipelineID(item) }}
+ </gl-link>
+
+ <div
+ v-if="pipelineDetailedStatus(item)"
+ :id="`terraformJobStatusContainer${item.name}`"
+ class="gl-my-2"
+ >
+ <ci-badge
+ :id="`terraformJobStatus${item.name}`"
+ :status="pipelineDetailedStatus(item)"
+ class="gl-py-1"
+ />
+
+ <gl-tooltip
+ :container="`terraformJobStatusContainer${item.name}`"
+ :target="`terraformJobStatus${item.name}`"
+ placement="right"
+ >
+ {{ $options.i18n.jobStatus }}
+ </gl-tooltip>
+ </div>
+ </div>
+ </template>
+
<template #cell(updated)="{ item }">
<p class="gl-m-0" data-testid="terraform-states-table-updated">
- <gl-sprintf :message="s__('Terraform|%{user} updated %{timeAgo}')">
+ <gl-sprintf :message="$options.i18n.updatedUser">
<template #user>
<span v-if="item.latestVersion">
{{ createdByUserName(item) }}
@@ -97,5 +185,9 @@ export default {
</gl-sprintf>
</p>
</template>
+
+ <template v-if="terraformAdmin" #cell(actions)="{ item }">
+ <state-actions :state="item" />
+ </template>
</gl-table>
</template>
diff --git a/app/assets/javascripts/terraform/components/states_table_actions.vue b/app/assets/javascripts/terraform/components/states_table_actions.vue
new file mode 100644
index 00000000000..44b0713e544
--- /dev/null
+++ b/app/assets/javascripts/terraform/components/states_table_actions.vue
@@ -0,0 +1,192 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlFormGroup,
+ GlFormInput,
+ GlIcon,
+ GlModal,
+ GlSprintf,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
+import lockState from '../graphql/mutations/lock_state.mutation.graphql';
+import unlockState from '../graphql/mutations/unlock_state.mutation.graphql';
+import removeState from '../graphql/mutations/remove_state.mutation.graphql';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ GlFormGroup,
+ GlFormInput,
+ GlIcon,
+ GlModal,
+ GlSprintf,
+ },
+ props: {
+ state: {
+ required: true,
+ type: Object,
+ },
+ },
+ data() {
+ return {
+ loading: false,
+ showRemoveModal: false,
+ removeConfirmText: '',
+ };
+ },
+ i18n: {
+ downloadJSON: s__('Terraform|Download JSON'),
+ lock: s__('Terraform|Lock'),
+ modalBody: s__(
+ 'Terraform|You are about to remove the State file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, only the state file with all its versions are to be removed. This action is non-revertible.',
+ ),
+ modalCancel: s__('Terraform|Cancel'),
+ modalHeader: s__('Terraform|Are you sure you want to remove the Terraform State %{name}?'),
+ modalInputLabel: s__(
+ 'Terraform|To remove the State file and its versions, type %{name} to confirm:',
+ ),
+ modalRemove: s__('Terraform|Remove'),
+ remove: s__('Terraform|Remove state file and versions'),
+ unlock: s__('Terraform|Unlock'),
+ },
+ computed: {
+ cancelModalProps() {
+ return {
+ text: this.$options.i18n.modalCancel,
+ attributes: [],
+ };
+ },
+ disableModalSubmit() {
+ return this.removeConfirmText !== this.state.name;
+ },
+ primaryModalProps() {
+ return {
+ text: this.$options.i18n.modalRemove,
+ attributes: [{ disabled: this.disableModalSubmit }, { variant: 'danger' }],
+ };
+ },
+ },
+ methods: {
+ hideModal() {
+ this.showRemoveModal = false;
+ this.removeConfirmText = '';
+ },
+ lock() {
+ this.stateMutation(lockState);
+ },
+ unlock() {
+ this.stateMutation(unlockState);
+ },
+ remove() {
+ if (!this.disableModalSubmit) {
+ this.hideModal();
+ this.stateMutation(removeState);
+ }
+ },
+ stateMutation(mutation) {
+ this.loading = true;
+ this.$apollo
+ .mutate({
+ mutation,
+ variables: {
+ stateID: this.state.id,
+ },
+ refetchQueries: () => ['getStates'],
+ awaitRefetchQueries: true,
+ notifyOnNetworkStatusChange: true,
+ })
+ .catch(() => {})
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown
+ icon="ellipsis_v"
+ right
+ :data-testid="`terraform-state-actions-${state.name}`"
+ :disabled="loading"
+ toggle-class="gl-px-3! gl-shadow-none!"
+ >
+ <template #button-content>
+ <gl-icon class="gl-mr-0" name="ellipsis_v" />
+ </template>
+
+ <gl-dropdown-item
+ v-if="state.latestVersion"
+ data-testid="terraform-state-download"
+ :download="`${state.name}.json`"
+ :href="state.latestVersion.downloadPath"
+ >
+ {{ $options.i18n.downloadJSON }}
+ </gl-dropdown-item>
+
+ <gl-dropdown-item v-if="state.lockedAt" data-testid="terraform-state-unlock" @click="unlock">
+ {{ $options.i18n.unlock }}
+ </gl-dropdown-item>
+
+ <gl-dropdown-item v-else data-testid="terraform-state-lock" @click="lock">
+ {{ $options.i18n.lock }}
+ </gl-dropdown-item>
+
+ <gl-dropdown-divider />
+
+ <gl-dropdown-item data-testid="terraform-state-remove" @click="showRemoveModal = true">
+ {{ $options.i18n.remove }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+
+ <gl-modal
+ :modal-id="`terraform-state-actions-remove-modal-${state.name}`"
+ :visible="showRemoveModal"
+ :action-primary="primaryModalProps"
+ :action-cancel="cancelModalProps"
+ @ok="remove"
+ @cancel="hideModal"
+ @close="hideModal"
+ @hide="hideModal"
+ >
+ <template #modal-title>
+ <gl-sprintf :message="$options.i18n.modalHeader">
+ <template #name>
+ <span>{{ state.name }}</span>
+ </template>
+ </gl-sprintf>
+ </template>
+
+ <p>
+ <gl-sprintf :message="$options.i18n.modalBody">
+ <template #name>
+ <span>{{ state.name }}</span>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-form-group>
+ <template #label>
+ <gl-sprintf :message="$options.i18n.modalInputLabel">
+ <template #name>
+ <code>{{ state.name }}</code>
+ </template>
+ </gl-sprintf>
+ </template>
+ <gl-form-input
+ :id="`terraform-state-remove-input-${state.name}`"
+ ref="input"
+ v-model="removeConfirmText"
+ type="text"
+ @keyup.enter="remove"
+ />
+ </gl-form-group>
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/terraform/components/terraform_list.vue b/app/assets/javascripts/terraform/components/terraform_list.vue
index f614bdc8d43..26a0bfe5fa5 100644
--- a/app/assets/javascripts/terraform/components/terraform_list.vue
+++ b/app/assets/javascripts/terraform/components/terraform_list.vue
@@ -15,13 +15,7 @@ export default {
...this.cursor,
};
},
- update: data => {
- return {
- count: data?.project?.terraformStates?.count,
- list: data?.project?.terraformStates?.nodes,
- pageInfo: data?.project?.terraformStates?.pageInfo,
- };
- },
+ update: data => data,
error() {
this.states = null;
},
@@ -46,6 +40,11 @@ export default {
required: true,
type: String,
},
+ terraformAdmin: {
+ required: false,
+ type: Boolean,
+ default: false,
+ },
},
data() {
return {
@@ -62,35 +61,34 @@ export default {
return this.$apollo.queries.states.loading;
},
pageInfo() {
- return this.states?.pageInfo || {};
+ return this.states?.project?.terraformStates?.pageInfo || {};
},
showPagination() {
return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
},
statesCount() {
- return this.states?.count;
+ return this.states?.project?.terraformStates?.count;
},
statesList() {
- return this.states?.list;
+ return this.states?.project?.terraformStates?.nodes;
},
},
methods: {
- updatePagination(item) {
- if (item === this.pageInfo.endCursor) {
- this.cursor = {
- first: MAX_LIST_COUNT,
- after: item,
- last: null,
- before: null,
- };
- } else {
- this.cursor = {
- first: null,
- after: null,
- last: MAX_LIST_COUNT,
- before: item,
- };
- }
+ nextPage(item) {
+ this.cursor = {
+ first: MAX_LIST_COUNT,
+ after: item,
+ last: null,
+ before: null,
+ };
+ },
+ prevPage(item) {
+ this.cursor = {
+ first: null,
+ after: null,
+ last: MAX_LIST_COUNT,
+ before: item,
+ };
},
},
};
@@ -111,14 +109,10 @@ export default {
<div v-else-if="statesList">
<div v-if="statesCount">
- <states-table :states="statesList" />
+ <states-table :states="statesList" :terraform-admin="terraformAdmin" />
<div v-if="showPagination" class="gl-display-flex gl-justify-content-center gl-mt-5">
- <gl-keyset-pagination
- v-bind="pageInfo"
- @prev="updatePagination"
- @next="updatePagination"
- />
+ <gl-keyset-pagination v-bind="pageInfo" @prev="prevPage" @next="nextPage" />
</div>
</div>
diff --git a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql
index c7e9700c696..70ba5c960be 100644
--- a/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql
+++ b/app/assets/javascripts/terraform/graphql/fragments/state_version.fragment.graphql
@@ -1,9 +1,26 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
fragment StateVersion on TerraformStateVersion {
+ downloadPath
+ serial
updatedAt
createdByUser {
...User
}
+
+ job {
+ detailedStatus {
+ detailsPath
+ group
+ icon
+ label
+ text
+ }
+
+ pipeline {
+ id
+ path
+ }
+ }
}
diff --git a/app/assets/javascripts/terraform/graphql/mutations/lock_state.mutation.graphql b/app/assets/javascripts/terraform/graphql/mutations/lock_state.mutation.graphql
new file mode 100644
index 00000000000..aea0f8b025a
--- /dev/null
+++ b/app/assets/javascripts/terraform/graphql/mutations/lock_state.mutation.graphql
@@ -0,0 +1,5 @@
+mutation lockState($stateID: TerraformStateID!) {
+ terraformStateLock(input: { id: $stateID }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/terraform/graphql/mutations/remove_state.mutation.graphql b/app/assets/javascripts/terraform/graphql/mutations/remove_state.mutation.graphql
new file mode 100644
index 00000000000..d85ebb9cea2
--- /dev/null
+++ b/app/assets/javascripts/terraform/graphql/mutations/remove_state.mutation.graphql
@@ -0,0 +1,5 @@
+mutation removeState($stateID: TerraformStateID!) {
+ terraformStateDelete(input: { id: $stateID }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/terraform/graphql/mutations/unlock_state.mutation.graphql b/app/assets/javascripts/terraform/graphql/mutations/unlock_state.mutation.graphql
new file mode 100644
index 00000000000..1909fe95cf3
--- /dev/null
+++ b/app/assets/javascripts/terraform/graphql/mutations/unlock_state.mutation.graphql
@@ -0,0 +1,5 @@
+mutation unlockState($stateID: TerraformStateID!) {
+ terraformStateUnlock(input: { id: $stateID }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/terraform/index.js b/app/assets/javascripts/terraform/index.js
index 579d2d14023..e27a29433f3 100644
--- a/app/assets/javascripts/terraform/index.js
+++ b/app/assets/javascripts/terraform/index.js
@@ -24,6 +24,7 @@ export default () => {
props: {
emptyStateImage,
projectPath,
+ terraformAdmin: el.hasAttribute('data-terraform-admin'),
},
});
},
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index dccd6807f13..e693c3e90a4 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -15,6 +15,7 @@ import { parseBoolean, spriteIcon } from '../lib/utils/common_utils';
import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { fixTitle, dispose } from '~/tooltips';
+import { loadCSSFile } from '../lib/utils/css_utils';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -48,6 +49,7 @@ function UsersSelect(currentUser, els, options = {}) {
options.todoStateFilter = $dropdown.data('todoStateFilter');
options.iid = $dropdown.data('iid');
options.issuableType = $dropdown.data('issuableType');
+ options.targetBranch = $dropdown.data('targetBranch');
const showNullUser = $dropdown.data('nullUser');
const defaultNullUser = $dropdown.data('nullUserDefault');
const showMenuAbove = $dropdown.data('showMenuAbove');
@@ -62,8 +64,7 @@ function UsersSelect(currentUser, els, options = {}) {
const abilityName = $dropdown.data('abilityName');
let $value = $block.find('.value');
const $collapsedSidebar = $block.find('.sidebar-collapsed-user');
- // eslint-disable-next-line no-jquery/no-fade
- const $loading = $block.find('.block-loading').fadeOut();
+ const $loading = $block.find('.block-loading').addClass('gl-display-none');
const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null;
let selectedId = $dropdown.data('selected');
let assignTo;
@@ -204,16 +205,14 @@ function UsersSelect(currentUser, els, options = {}) {
const data = {};
data[abilityName] = {};
data[abilityName].assignee_id = selected != null ? selected : null;
- // eslint-disable-next-line no-jquery/no-fade
- $loading.removeClass('hidden').fadeIn();
+ $loading.removeClass('gl-display-none');
$dropdown.trigger('loading.gl.dropdown');
return axios.put(issueURL, data).then(({ data }) => {
let user = {};
let tooltipTitle = user.name;
$dropdown.trigger('loaded.gl.dropdown');
- // eslint-disable-next-line no-jquery/no-fade
- $loading.fadeOut();
+ $loading.addClass('gl-display-none');
if (data.assignee) {
user = {
name: data.assignee.name,
@@ -584,7 +583,14 @@ function UsersSelect(currentUser, els, options = {}) {
img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`;
}
- return userSelect.renderRow(options.issuableType, user, selected, username, img);
+ return userSelect.renderRow(
+ options.issuableType,
+ user,
+ selected,
+ username,
+ img,
+ elsClassName,
+ );
},
});
});
@@ -592,92 +598,97 @@ function UsersSelect(currentUser, els, options = {}) {
if ($('.ajax-users-select').length) {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
- $('.ajax-users-select').each((i, select) => {
- const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP);
- options.skipLdap = $(select).hasClass('skip_ldap');
- const showNullUser = $(select).data('nullUser');
- const showAnyUser = $(select).data('anyUser');
- const showEmailUser = $(select).data('emailUser');
- const firstUser = $(select).data('firstUser');
- return $(select).select2({
- placeholder: __('Search for a user'),
- multiple: $(select).hasClass('multiselect'),
- minimumInputLength: 0,
- query(query) {
- return userSelect.users(query.term, options, users => {
- let name;
- const data = {
- results: users,
- };
- if (query.term.length === 0) {
- if (firstUser) {
- // Move current user to the front of the list
- const ref = data.results;
-
- for (let index = 0, len = ref.length; index < len; index += 1) {
- const obj = ref[index];
- if (obj.username === firstUser) {
- data.results.splice(index, 1);
- data.results.unshift(obj);
- break;
+ // eslint-disable-next-line promise/no-nesting
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ $('.ajax-users-select').each((i, select) => {
+ const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP);
+ options.skipLdap = $(select).hasClass('skip_ldap');
+ const showNullUser = $(select).data('nullUser');
+ const showAnyUser = $(select).data('anyUser');
+ const showEmailUser = $(select).data('emailUser');
+ const firstUser = $(select).data('firstUser');
+ return $(select).select2({
+ placeholder: __('Search for a user'),
+ multiple: $(select).hasClass('multiselect'),
+ minimumInputLength: 0,
+ query(query) {
+ return userSelect.users(query.term, options, users => {
+ let name;
+ const data = {
+ results: users,
+ };
+ if (query.term.length === 0) {
+ if (firstUser) {
+ // Move current user to the front of the list
+ const ref = data.results;
+
+ for (let index = 0, len = ref.length; index < len; index += 1) {
+ const obj = ref[index];
+ if (obj.username === firstUser) {
+ data.results.splice(index, 1);
+ data.results.unshift(obj);
+ break;
+ }
+ }
+ }
+ if (showNullUser) {
+ const nullUser = {
+ name: s__('UsersSelect|Unassigned'),
+ id: 0,
+ };
+ data.results.unshift(nullUser);
+ }
+ if (showAnyUser) {
+ name = showAnyUser;
+ if (name === true) {
+ name = s__('UsersSelect|Any User');
+ }
+ const anyUser = {
+ name,
+ id: null,
+ };
+ data.results.unshift(anyUser);
}
}
- }
- if (showNullUser) {
- const nullUser = {
- name: s__('UsersSelect|Unassigned'),
- id: 0,
- };
- data.results.unshift(nullUser);
- }
- if (showAnyUser) {
- name = showAnyUser;
- if (name === true) {
- name = s__('UsersSelect|Any User');
+ if (
+ showEmailUser &&
+ data.results.length === 0 &&
+ query.term.match(/^[^@]+@[^@]+$/)
+ ) {
+ const trimmed = query.term.trim();
+ const emailUser = {
+ name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }),
+ username: trimmed,
+ id: trimmed,
+ invite: true,
+ };
+ data.results.unshift(emailUser);
}
- const anyUser = {
- name,
- id: null,
- };
- data.results.unshift(anyUser);
- }
- }
- if (
- showEmailUser &&
- data.results.length === 0 &&
- query.term.match(/^[^@]+@[^@]+$/)
- ) {
- const trimmed = query.term.trim();
- const emailUser = {
- name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }),
- username: trimmed,
- id: trimmed,
- invite: true,
- };
- data.results.unshift(emailUser);
- }
- return query.callback(data);
+ return query.callback(data);
+ });
+ },
+ initSelection() {
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return userSelect.initSelection.apply(userSelect, args);
+ },
+ formatResult() {
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return userSelect.formatResult.apply(userSelect, args);
+ },
+ formatSelection() {
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return userSelect.formatSelection.apply(userSelect, args);
+ },
+ dropdownCssClass: 'ajax-users-dropdown',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup(m) {
+ return m;
+ },
});
- },
- initSelection() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return userSelect.initSelection.apply(userSelect, args);
- },
- formatResult() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return userSelect.formatResult.apply(userSelect, args);
- },
- formatSelection() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return userSelect.formatSelection.apply(userSelect, args);
- },
- dropdownCssClass: 'ajax-users-dropdown',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
- });
- });
+ });
+ })
+ .catch(() => {});
})
.catch(() => {});
}
@@ -743,8 +754,17 @@ UsersSelect.prototype.users = function(query, options, callback) {
...getAjaxUsersSelectParams(options, AJAX_USERS_SELECT_PARAMS_MAP),
};
- if (options.issuableType === 'merge_request') {
+ const isMergeRequest = options.issuableType === 'merge_request';
+ const isEditMergeRequest = !options.issuableType && (options.iid && options.targetBranch);
+ const isNewMergeRequest = !options.issuableType && (!options.iid && options.targetBranch);
+
+ if (isMergeRequest || isEditMergeRequest || isNewMergeRequest) {
params.merge_request_iid = options.iid || null;
+ params.approval_rules = true;
+ }
+
+ if (isNewMergeRequest) {
+ params.target_branch = options.targetBranch || null;
}
return axios.get(url, { params }).then(({ data }) => {
@@ -759,7 +779,14 @@ UsersSelect.prototype.buildUrl = function(url) {
return url;
};
-UsersSelect.prototype.renderRow = function(issuableType, user, selected, username, img) {
+UsersSelect.prototype.renderRow = function(
+ issuableType,
+ user,
+ selected,
+ username,
+ img,
+ elsClassName,
+) {
const tooltip = issuableType === 'merge_request' && !user.can_merge ? __('Cannot merge') : '';
const tooltipClass = tooltip ? `has-tooltip` : '';
const selectedClass = selected === true ? 'is-active' : '';
@@ -773,10 +800,15 @@ UsersSelect.prototype.renderRow = function(issuableType, user, selected, usernam
<a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
<span class="d-flex flex-column overflow-hidden">
- <strong class="dropdown-menu-user-full-name">
+ <strong class="dropdown-menu-user-full-name gl-font-weight-bold">
${escape(user.name)}
</strong>
- ${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''}
+ ${
+ username
+ ? `<span class="dropdown-menu-user-username gl-text-gray-400">${username}</span>`
+ : ''
+ }
+ ${this.renderApprovalRules(elsClassName, user.applicable_approval_rules)}
</span>
</a>
</li>
@@ -790,7 +822,7 @@ UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) {
const mergeIcon =
issuableType === 'merge_request' && !user.can_merge
- ? '<i class="fa fa-exclamation-triangle merge-icon"></i>'
+ ? spriteIcon('warning-solid', 's12 merge-icon')
: '';
return `<span class="position-relative mr-2">
@@ -799,4 +831,22 @@ UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) {
</span>`;
};
+UsersSelect.prototype.renderApprovalRules = function(elsClassName, approvalRules = []) {
+ if (!gon.features?.reviewerApprovalRules || !elsClassName?.includes('reviewer')) {
+ return '';
+ }
+
+ const count = approvalRules.length;
+ const [rule] = approvalRules;
+ const countText = sprintf(__('(+%{count}&nbsp;rules)'), { count });
+ const renderApprovalRulesCount = count > 1 ? `<span class="ml-1">${countText}</span>` : '';
+
+ return count
+ ? `<div class="gl-display-flex gl-font-sm">
+ <span class="gl-text-truncate" title="${rule.name}">${rule.name}</span>
+ ${renderApprovalRulesCount}
+ </div>`
+ : '';
+};
+
export default UsersSelect;
diff --git a/app/assets/javascripts/version_check_image.js b/app/assets/javascripts/version_check_image.js
index ec515e892c6..4e00e0f11f7 100644
--- a/app/assets/javascripts/version_check_image.js
+++ b/app/assets/javascripts/version_check_image.js
@@ -1,5 +1,6 @@
export default class VersionCheckImage {
static bindErrorEvent(imageElement) {
+ // eslint-disable-next-line @gitlab/no-global-event-off
imageElement.off('error').on('error', () => imageElement.hide());
}
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js
index 66de4f8b682..29d067a46a6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js
@@ -6,6 +6,7 @@ export const RUNNING = 'running';
export const SUCCESS = 'success';
export const FAILED = 'failed';
export const CANCELED = 'canceled';
+export const SKIPPED = 'skipped';
// ACTION STATUSES
export const STOPPING = 'stopping';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index 2f922b990d9..390469dec24 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -4,7 +4,15 @@ import { __ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import MemoryUsage from './memory_usage.vue';
-import { MANUAL_DEPLOY, WILL_DEPLOY, RUNNING, SUCCESS, FAILED, CANCELED } from './constants';
+import {
+ MANUAL_DEPLOY,
+ WILL_DEPLOY,
+ RUNNING,
+ SUCCESS,
+ FAILED,
+ CANCELED,
+ SKIPPED,
+} from './constants';
export default {
name: 'DeploymentInfo',
@@ -38,6 +46,7 @@ export default {
[SUCCESS]: __('Deployed to'),
[FAILED]: __('Failed to deploy to'),
[CANCELED]: __('Canceled deployment to'),
+ [SKIPPED]: __('Skipped deployment to'),
},
computed: {
deployTimeago() {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index d5fdbe726e9..6628ab7be83 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -7,12 +7,14 @@ import {
GlDropdownSectionHeader,
GlDropdownItem,
GlTooltipDirective,
+ GlModalDirective,
} from '@gitlab/ui';
import { n__, s__, sprintf } from '~/locale';
import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility';
import clipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import MrWidgetIcon from './mr_widget_icon.vue';
+import MrWidgetHowToMergeModal from './mr_widget_how_to_merge_modal.vue';
export default {
name: 'MRWidgetHeader',
@@ -20,6 +22,7 @@ export default {
clipboardButton,
TooltipOnTruncate,
MrWidgetIcon,
+ MrWidgetHowToMergeModal,
GlButton,
GlDropdown,
GlDropdownSectionHeader,
@@ -27,6 +30,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModalDirective,
},
props: {
mr: {
@@ -82,6 +86,9 @@ export default {
)
: '';
},
+ isFork() {
+ return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath;
+ },
},
};
</script>
@@ -140,13 +147,22 @@ export default {
</gl-button>
</span>
<gl-button
+ v-gl-modal-directive="'modal-merge-info'"
:disabled="mr.sourceBranchRemoved"
- data-target="#modal_merge_info"
- data-toggle="modal"
class="js-check-out-branch gl-mr-3"
>
{{ s__('mrWidget|Check out branch') }}
</gl-button>
+ <mr-widget-how-to-merge-modal
+ :is-fork="isFork"
+ :can-merge="mr.canMerge"
+ :source-branch="mr.sourceBranch"
+ :source-project="mr.sourceProject"
+ :source-project-path="mr.sourceProjectFullPath"
+ :target-branch="mr.targetBranch"
+ :source-project-default-url="mr.sourceProjectDefaultUrl"
+ :reviewing-docs-path="mr.reviewingDocsPath"
+ />
</template>
<gl-dropdown
v-gl-tooltip
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
new file mode 100644
index 00000000000..785e8ef8e8f
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_how_to_merge_modal.vue
@@ -0,0 +1,172 @@
+<script>
+/* eslint-disable @gitlab/require-i18n-strings */
+import { GlModal, GlLink, GlSprintf } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { __ } from '~/locale';
+
+export default {
+ i18n: {
+ steps: {
+ step1: {
+ label: __('Step 1.'),
+ help: __('Fetch and check out the branch for this merge request'),
+ },
+ step2: {
+ label: __('Step 2.'),
+ help: __('Review the changes locally'),
+ },
+ step3: {
+ label: __('Step 3.'),
+ help: __('Merge the branch and fix any conflicts that come up'),
+ },
+ step4: {
+ label: __('Step 4.'),
+ help: __('Push the result of the merge to GitLab'),
+ },
+ },
+ copyCommands: __('Copy commands'),
+ tip: __(
+ '%{strongStart}Tip:%{strongEnd} You can also checkout merge requests locally by %{linkStart}following these guidelines%{linkEnd}',
+ ),
+ title: __('Check out, review, and merge locally'),
+ },
+ components: {
+ GlModal,
+ ClipboardButton,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ canMerge: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isFork: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ sourceBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sourceProjectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ targetBranch: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sourceProjectDefaultUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ reviewingDocsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ mergeInfo1() {
+ return this.isFork
+ ? `git fetch "${this.sourceProjectDefaultUrl}" ${this.sourceBranch}\ngit checkout -b "${this.sourceProjectPath}-${this.sourceBranch}" FETCH_HEAD`
+ : `git fetch origin\ngit checkout -b "${this.sourceBranch}" "origin/${this.sourceBranch}"`;
+ },
+ mergeInfo2() {
+ return this.isFork
+ ? `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceProjectPath}-${this.sourceBranch}"`
+ : `git fetch origin\ngit checkout "${this.targetBranch}"\ngit merge --no-ff "${this.sourceBranch}"`;
+ },
+ mergeInfo3() {
+ return this.canMerge
+ ? `git push origin "${this.targetBranch}"`
+ : __('Note that pushing to GitLab requires write access to this repository.');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ modal-id="modal-merge-info"
+ :no-enforce-focus="true"
+ :title="$options.i18n.title"
+ no-fade
+ hide-footer
+ >
+ <p>
+ <strong>
+ {{ $options.i18n.steps.step1.label }}
+ </strong>
+ {{ $options.i18n.steps.step1.help }}
+ </p>
+ <div class="gl-display-flex">
+ <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
+ mergeInfo1
+ }}</pre>
+ <clipboard-button
+ :text="mergeInfo1"
+ :title="$options.i18n.copyCommands"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
+ />
+ </div>
+
+ <p>
+ <strong>
+ {{ $options.i18n.steps.step2.label }}
+ </strong>
+ {{ $options.i18n.steps.step2.help }}
+ </p>
+ <p>
+ <strong>
+ {{ $options.i18n.steps.step3.label }}
+ </strong>
+ {{ $options.i18n.steps.step3.help }}
+ </p>
+ <div class="gl-display-flex">
+ <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
+ mergeInfo2
+ }}</pre>
+ <clipboard-button
+ :text="mergeInfo2"
+ :title="$options.i18n.copyCommands"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
+ />
+ </div>
+ <p>
+ <strong>
+ {{ $options.i18n.steps.step4.label }}
+ </strong>
+ {{ $options.i18n.steps.step4.help }}
+ </p>
+ <div class="gl-display-flex">
+ <pre class="gl-overflow-scroll gl-w-full" data-testid="how-to-merge-instructions">{{
+ mergeInfo3
+ }}</pre>
+ <clipboard-button
+ :text="mergeInfo3"
+ :title="$options.i18n.copyCommands"
+ class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0"
+ />
+ </div>
+ <p v-if="reviewingDocsPath">
+ <gl-sprintf :message="$options.i18n.tip">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ <template #link="{ content }">
+ <gl-link class="gl-display-inline-block" :href="reviewingDocsPath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
index 53bf9d5ab6f..1727383ea2c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
@@ -1,8 +1,15 @@
<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
export default {
name: 'MRWidgetMergeHelp',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlModalDirective,
+ },
props: {
missingBranch: {
type: String,
@@ -31,13 +38,12 @@ export default {
{{ s__('mrWidget|You can merge this merge request manually using the') }}
</template>
- <button
- type="button"
- class="btn-link btn-blank js-open-modal-help"
- data-toggle="modal"
- data-target="#modal_merge_info"
+ <gl-button
+ v-gl-modal-directive="'modal-merge-info'"
+ variant="link"
+ class="gl-mt-n2 js-open-modal-help"
>
{{ s__('mrWidget|command line') }}
- </button>
+ </gl-button>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index 55efd7e7d3b..dffe3cab904 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -1,7 +1,6 @@
<script>
import { isNumber } from 'lodash';
import ArtifactsApp from './artifacts_list_app.vue';
-import Deployment from './deployment/deployment.vue';
import MrWidgetContainer from './mr_widget_container.vue';
import MrWidgetPipeline from './mr_widget_pipeline.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -18,7 +17,7 @@ export default {
name: 'MrWidgetPipelineContainer',
components: {
ArtifactsApp,
- Deployment,
+ Deployment: () => import('./deployment/deployment.vue'),
MrWidgetContainer,
MrWidgetPipeline,
MergeTrainPositionIndicator: () =>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 82566682bca..bc23ca6b1fc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -1,10 +1,11 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import ciIcon from '../../vue_shared/components/ci_icon.vue';
export default {
components: {
ciIcon,
+ GlButton,
GlLoadingIcon,
},
props: {
@@ -32,21 +33,23 @@ export default {
};
</script>
<template>
- <div class="d-flex align-self-start">
+ <div class="gl-display-flex gl-align-self-start">
<div class="square s24 h-auto d-flex-center gl-mr-3">
- <div v-if="isLoading" class="mr-widget-icon d-inline-flex">
- <gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" />
+ <div v-if="isLoading" class="mr-widget-icon gl-display-inline-flex">
+ <gl-loading-icon size="md" class="mr-loading-icon gl-display-inline-flex" />
</div>
<ci-icon v-else :status="statusObj" :size="24" />
</div>
- <button
+ <gl-button
v-if="showDisabledButton"
type="button"
- class="js-disabled-merge-button btn btn-success btn-sm"
- disabled="true"
+ category="primary"
+ variant="success"
+ class="js-disabled-merge-button"
+ :disabled="true"
>
{{ s__('mrWidget|Merge') }}
- </button>
+ </gl-button>
</div>
</template>
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 d421b744fa1..87c59e5ece9 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,14 +1,47 @@
<script>
import $ from 'jquery';
import { escape } from 'lodash';
+import { GlButton, GlModalDirective, GlSkeletonLoader } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import StatusIcon from '../mr_widget_status_icon.vue';
+import userPermissionsQuery from '../../queries/permissions.query.graphql';
+import conflictsStateQuery from '../../queries/states/conflicts.query.graphql';
export default {
name: 'MRWidgetConflicts',
components: {
+ GlSkeletonLoader,
StatusIcon,
+ GlButton,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ apollo: {
+ userPermissions: {
+ query: userPermissionsQuery,
+ skip() {
+ return !this.glFeatures.mergeRequestWidgetGraphql;
+ },
+ variables() {
+ return this.mergeRequestQueryVariables;
+ },
+ update: data => data.project.mergeRequest.userPermissions,
+ },
+ stateData: {
+ query: conflictsStateQuery,
+ skip() {
+ return !this.glFeatures.mergeRequestWidgetGraphql;
+ },
+ variables() {
+ return this.mergeRequestQueryVariables;
+ },
+ update: data => data.project.mergeRequest,
+ },
},
props: {
/* TODO: This is providing all store and service down when it
@@ -19,21 +52,72 @@ export default {
default: () => ({}),
},
},
+ data() {
+ return {
+ userPermissions: {},
+ stateData: {},
+ };
+ },
computed: {
+ isLoading() {
+ return (
+ this.glFeatures.mergeRequestWidgetGraphql &&
+ this.$apollo.queries.userPermissions.loading &&
+ this.$apollo.queries.stateData.loading
+ );
+ },
+ canPushToSourceBranch() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ return this.userPermissions.pushToSourceBranch;
+ }
+
+ return this.mr.canPushToSourceBranch;
+ },
+ canMerge() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ return this.userPermissions.canMerge;
+ }
+
+ return this.mr.canMerge;
+ },
+ shouldBeRebased() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ return this.stateData.shouldBeRebased;
+ }
+
+ return this.mr.shouldBeRebased;
+ },
+ sourceBranchProtected() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ return this.stateData.sourceBranchProtected;
+ }
+
+ 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.mr.canPushToSourceBranch;
+ return this.mr.conflictResolutionPath && this.canPushToSourceBranch;
},
showPopover() {
- return this.showResolveButton && this.mr.sourceBranchProtected;
+ return this.showResolveButton && this.sourceBranchProtected;
},
},
- mounted() {
- if (this.showPopover) {
+ watch: {
+ showPopover: {
+ handler(newVal) {
+ if (newVal) {
+ this.$nextTick(this.initPopover);
+ }
+ },
+ immediate: true,
+ },
+ },
+ methods: {
+ initPopover() {
const $el = $(this.$refs.popover);
$el
@@ -63,7 +147,7 @@ export default {
.on('show.bs.popover', () => {
window.addEventListener('scroll', togglePopover.bind($el, false), { once: true });
});
- }
+ },
},
};
</script>
@@ -71,40 +155,46 @@ export default {
<div class="mr-widget-body media">
<status-icon :show-disabled-button="true" status="warning" />
- <div class="media-body space-children">
- <span v-if="mr.shouldBeRebased" class="bold">
+ <div v-if="isLoading" class="gl-ml-4 gl-w-full mr-conflict-loader">
+ <gl-skeleton-loader :width="334" :height="30">
+ <rect x="0" y="7" width="150" height="16" rx="4" />
+ <rect x="158" y="7" width="84" height="16" rx="4" />
+ <rect x="250" y="7" width="84" height="16" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ <div v-else class="media-body space-children">
+ <span v-if="shouldBeRebased" class="bold">
{{
s__(`mrWidget|Fast-forward merge is not possible.
-To merge this request, first rebase locally.`)
+ To merge this request, first rebase locally.`)
}}
</span>
<template v-else>
<span class="bold">
- {{ s__('mrWidget|There are merge conflicts') }}<span v-if="!mr.canMerge">.</span>
- <span v-if="!mr.canMerge">
+ {{ s__('mrWidget|There are merge conflicts') }}<span v-if="!canMerge">.</span>
+ <span v-if="!canMerge">
{{
s__(`mrWidget|Resolve these conflicts or ask someone
- with write access to this repository to merge it locally`)
+ with write access to this repository to merge it locally`)
}}
</span>
</span>
<span v-if="showResolveButton" ref="popover">
- <a
- :href="mr.conflictResolutionPath"
- :disabled="mr.sourceBranchProtected"
- class="js-resolve-conflicts-button btn btn-default btn-sm"
+ <gl-button
+ :href="!sourceBranchProtected && mr.conflictResolutionPath"
+ :disabled="sourceBranchProtected"
+ class="js-resolve-conflicts-button"
>
{{ s__('mrWidget|Resolve conflicts') }}
- </a>
+ </gl-button>
</span>
- <button
- v-if="mr.canMerge"
- class="js-merge-locally-button btn btn-default btn-sm"
- data-toggle="modal"
- data-target="#modal_merge_info"
+ <gl-button
+ v-if="canMerge"
+ v-gl-modal-directive="'modal-merge-info'"
+ class="js-merge-locally-button"
>
{{ s__('mrWidget|Merge locally') }}
- </button>
+ </gl-button>
</template>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index 6489569cf68..8511797286d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -2,6 +2,9 @@
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import statusIcon from '../mr_widget_status_icon.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
+import missingBranchQuery from '../../queries/states/missing_branch.query.graphql';
export default {
name: 'MRWidgetMissingBranch',
@@ -12,15 +15,38 @@ export default {
GlIcon,
statusIcon,
},
+ mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ apollo: {
+ state: {
+ query: missingBranchQuery,
+ skip() {
+ return !this.glFeatures.mergeRequestWidgetGraphql;
+ },
+ variables() {
+ return this.mergeRequestQueryVariables;
+ },
+ update: data => data.project.mergeRequest,
+ },
+ },
props: {
mr: {
type: Object,
required: true,
},
},
+ data() {
+ return { state: {} };
+ },
computed: {
+ sourceBranchRemoved() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ return !this.state.sourceBranchExists;
+ }
+
+ return this.mr.sourceBranchRemoved;
+ },
missingBranchName() {
- return this.mr.sourceBranchRemoved ? 'source' : 'target';
+ return this.sourceBranchRemoved ? 'source' : 'target';
},
missingBranchNameMessage() {
return sprintf(
@@ -49,7 +75,7 @@ export default {
<div class="media-body space-children">
<span class="bold js-branch-text">
- <span class="capitalize"> {{ missingBranchName }} </span>
+ <span class="capitalize" data-testid="missingBranchName"> {{ missingBranchName }} </span>
{{ s__('mrWidget|branch does not exist.') }} {{ missingBranchNameMessage }}
<gl-icon v-gl-tooltip :title="message" :aria-label="message" name="question-o" />
</span>
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 ff0d065c71d..1c9909e7178 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -1,10 +1,11 @@
<script>
-import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
import { SQUASH_BEFORE_MERGE } from '../../i18n';
export default {
components: {
GlIcon,
+ GlFormCheckbox,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -32,32 +33,23 @@ export default {
tooltipTitle() {
return this.isDisabled ? this.$options.i18n.tooltipTitle : null;
},
- tooltipFocusable() {
- return this.isDisabled ? '0' : null;
- },
},
};
</script>
<template>
- <div class="inline">
- <label
+ <div class="gl-display-flex gl-align-items-center">
+ <gl-form-checkbox
v-gl-tooltip
- :class="{ 'gl-text-gray-400': isDisabled }"
- :tabindex="tooltipFocusable"
- data-testid="squashLabel"
+ :checked="value"
+ :disabled="isDisabled"
+ name="squash"
+ class="qa-squash-checkbox js-squash-checkbox gl-mb-0 gl-mr-2"
:title="tooltipTitle"
+ @change="checked => $emit('input', checked)"
>
- <input
- :checked="value"
- :disabled="isDisabled"
- type="checkbox"
- name="squash"
- class="qa-squash-checkbox js-squash-checkbox"
- @change="$emit('input', $event.target.checked)"
- />
{{ $options.i18n.checkboxLabel }}
- </label>
+ </gl-form-checkbox>
<a
v-if="helpPath"
v-gl-tooltip
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 e7f0977778e..3f1f2144d8e 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
@@ -16,7 +16,6 @@ import WidgetHeader from './components/mr_widget_header.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
-import Deployment from './components/deployment/deployment.vue';
import WidgetRelatedLinks from './components/mr_widget_related_links.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import MergedState from './components/states/mr_widget_merged.vue';
@@ -63,7 +62,6 @@ export default {
'mr-widget-suggest-pipeline': WidgetSuggestPipeline,
'mr-widget-merge-help': WidgetMergeHelp,
MrWidgetPipelineContainer,
- Deployment,
'mr-widget-related-links': WidgetRelatedLinks,
MrWidgetAlertMessage,
'mr-widget-merged': MergedState,
@@ -155,10 +153,7 @@ export default {
},
shouldSuggestPipelines() {
return (
- gon.features?.suggestPipeline &&
- !this.mr.hasCI &&
- this.mr.mergeRequestAddCiConfigPath &&
- !this.mr.isDismissedSuggestPipeline
+ !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath && !this.mr.isDismissedSuggestPipeline
);
},
shouldRenderCodeQuality() {
@@ -472,8 +467,10 @@ export default {
<security-reports-app
v-if="shouldRenderSecurityReport"
:pipeline-id="mr.pipeline.id"
- :project-id="mr.targetProjectId"
+ :project-id="mr.sourceProjectId"
:security-reports-docs-path="mr.securityReportsDocsPath"
+ :target-project-full-path="mr.targetProjectFullPath"
+ :mr-iid="mr.iid"
/>
<grouped-test-reports-app
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql
new file mode 100644
index 00000000000..ae2a67440fe
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/permissions.query.graphql
@@ -0,0 +1,10 @@
+query userPermissionsQuery($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $iid) {
+ userPermissions {
+ canMerge
+ pushToSourceBranch
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql
new file mode 100644
index 00000000000..186c0e64561
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/conflicts.query.graphql
@@ -0,0 +1,8 @@
+query workInProgressQuery($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $iid) {
+ shouldBeRebased
+ sourceBranchProtected
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql
new file mode 100644
index 00000000000..ea95218aec6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/missing_branch.query.graphql
@@ -0,0 +1,7 @@
+query missingBranchQuery($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $iid) {
+ sourceBranchExists
+ }
+ }
+}
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 8b235b20ad4..f50b6caf0f5 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
@@ -220,6 +220,7 @@ export default class MergeRequestStore {
this.sourceProjectFullPath = data.source_project_full_path;
this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path;
this.conflictsDocsPath = data.conflicts_docs_path;
+ this.reviewingDocsPath = data.reviewing_and_managing_merge_requests_docs_path;
this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
this.securityApprovalsHelpPagePath = data.security_approvals_help_page_path;
this.eligibleApproversDocsPath = data.eligible_approvers_docs_path;
@@ -229,6 +230,7 @@ export default class MergeRequestStore {
this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path;
this.humanAccess = data.human_access;
this.newPipelinePath = data.new_project_pipeline_path;
+ this.sourceProjectDefaultUrl = data.source_project_default_url;
this.userCalloutsPath = data.user_callouts_path;
this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id;
this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline;
@@ -240,6 +242,10 @@ export default class MergeRequestStore {
this.baseBlobPath = blobPath.base_path || '';
this.codequalityHelpPath = data.codequality_help_path;
this.codeclimate = data.codeclimate;
+
+ // Security reports
+ this.sastComparisonPath = data.sast_comparison_path;
+ this.secretScanningComparisonPath = data.secret_scanning_comparison_path;
}
get isNothingToMergeState() {
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 7a687ea4ad0..9a6433963bc 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -1,7 +1,7 @@
<script>
/* eslint-disable vue/no-v-html */
import { groupBy } from 'lodash';
-import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '~/locale';
@@ -10,8 +10,8 @@ const NO_USER_ID = -1;
export default {
components: {
+ GlButton,
GlIcon,
- GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -64,7 +64,7 @@ export default {
methods: {
getAwardClassBindings(awardList) {
return {
- active: this.hasReactionByCurrentUser(awardList),
+ selected: this.hasReactionByCurrentUser(awardList),
disabled: this.currentUserId === NO_USER_ID,
};
},
@@ -150,40 +150,39 @@ export default {
<template>
<div class="awards js-awards-block">
- <button
+ <gl-button
v-for="awardList in groupedAwards"
:key="awardList.name"
v-gl-tooltip.viewport
+ class="gl-mr-3"
:class="awardList.classes"
:title="awardList.title"
data-testid="award-button"
- class="btn award-control"
- type="button"
@click="handleAward(awardList.name)"
>
- <span data-testid="award-html" v-html="awardList.html"></span>
- <span class="award-control-text js-counter">{{ awardList.list.length }}</span>
- </button>
+ <template #emoji>
+ <span class="award-emoji-block" data-testid="award-html" v-html="awardList.html"></span>
+ </template>
+ <span class="js-counter">{{ awardList.list.length }}</span>
+ </gl-button>
<div v-if="canAwardEmoji" class="award-menu-holder">
- <button
+ <gl-button
v-gl-tooltip.viewport
:class="addButtonClass"
- class="award-control btn js-add-award"
+ class="add-reaction-button js-add-award"
title="Add reaction"
:aria-label="__('Add reaction')"
- type="button"
>
- <span class="award-control-icon award-control-icon-neutral">
- <gl-icon aria-hidden="true" name="slight-smile" />
+ <span class="reaction-control-icon reaction-control-icon-neutral">
+ <gl-icon name="slight-smile" />
</span>
- <span class="award-control-icon award-control-icon-positive">
- <gl-icon aria-hidden="true" name="smiley" />
+ <span class="reaction-control-icon reaction-control-icon-positive">
+ <gl-icon name="smiley" />
</span>
- <span class="award-control-icon award-control-icon-super-positive">
- <gl-icon aria-hidden="true" name="smiley" />
+ <span class="reaction-control-icon reaction-control-icon-super-positive">
+ <gl-icon name="smile" />
</span>
- <gl-loading-icon size="md" color="dark" class="award-control-icon-loading" />
- </button>
+ </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js
index d4c1808eec2..106dd7a3b97 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/constants.js
@@ -1,3 +1 @@
export const HIGHLIGHT_CLASS_NAME = 'hll';
-
-export default {};
diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue
deleted file mode 100644
index 56bafebf4ce..00000000000
--- a/app/assets/javascripts/vue_shared/components/callout.vue
+++ /dev/null
@@ -1,24 +0,0 @@
-<script>
-const calloutVariants = ['danger', 'success', 'info', 'warning'];
-
-export default {
- props: {
- category: {
- type: String,
- required: false,
- default: calloutVariants[0],
- validator: value => calloutVariants.includes(value),
- },
- message: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
-</script>
-<template>
- <div :class="`bs-callout bs-callout-${category}`" role="alert" aria-live="assertive">
- {{ message }} <slot></slot>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
index 1b7e51b7d02..f388a468fd2 100644
--- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue
@@ -20,6 +20,7 @@ import CiIcon from './ci_icon.vue';
* - Pipeline show view - header
* - Job show view - header
* - MR widget
+ * - Terraform table
*/
export default {
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index d775a093f5f..07bd6019b80 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -63,5 +63,7 @@ export default {
};
</script>
<template>
- <span :class="cssClass"> <gl-icon :name="icon" :size="size" :class="cssClasses" /> </span>
+ <span :class="cssClass">
+ <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" />
+ </span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index 960551fae91..bf1361f1a6a 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -84,5 +84,8 @@ export default {
:size="size"
icon="copy-to-clipboard"
:aria-label="__('Copy this value')"
- />
+ v-on="$listeners"
+ >
+ <slot></slot>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
new file mode 100644
index 00000000000..6977692e30c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
@@ -0,0 +1,142 @@
+<script>
+/**
+ * Renders a color picker input with preset colors to choose from
+ *
+ * @example
+ * <color-picker :label="__('Background color')" set-color="#FF0000" />
+ */
+import { GlFormGroup, GlFormInput, GlFormInputGroup, GlLink, GlTooltipDirective } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const VALID_RGB_HEX_COLOR = /^#([0-9A-F]{3}){1,2}$/i;
+const PREVIEW_COLOR_DEFAULT_CLASSES =
+ 'gl-relative gl-w-7 gl-bg-gray-10 gl-rounded-top-left-base gl-rounded-bottom-left-base';
+
+export default {
+ name: 'ColorPicker',
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlFormInputGroup,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ setColor: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ selectedColor: this.setColor.trim() || '',
+ };
+ },
+ computed: {
+ description() {
+ return this.hasSuggestedColors
+ ? this.$options.i18n.fullDescription
+ : this.$options.i18n.shortDescription;
+ },
+ suggestedColors() {
+ return gon.suggested_label_colors;
+ },
+ previewColor() {
+ if (this.isValidColor) {
+ return { backgroundColor: this.selectedColor };
+ }
+
+ return {};
+ },
+ previewColorClasses() {
+ const borderStyle = this.isInvalidColor
+ ? 'gl-inset-border-1-red-500'
+ : 'gl-inset-border-1-gray-400';
+
+ return `${PREVIEW_COLOR_DEFAULT_CLASSES} ${borderStyle}`;
+ },
+ hasSuggestedColors() {
+ return Object.keys(this.suggestedColors).length;
+ },
+ isInvalidColor() {
+ return this.isValidColor === false;
+ },
+ isValidColor() {
+ if (this.selectedColor === '') {
+ return null;
+ }
+
+ return VALID_RGB_HEX_COLOR.test(this.selectedColor);
+ },
+ },
+ methods: {
+ handleColorChange(color) {
+ this.selectedColor = color.trim();
+
+ if (this.isValidColor) {
+ this.$emit('input', this.selectedColor);
+ }
+ },
+ },
+ i18n: {
+ fullDescription: __('Choose any color. Or you can choose one of the suggested colors below'),
+ shortDescription: __('Choose any color'),
+ invalid: __('Please enter a valid hex (#RRGGBB or #RGB) color value'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-form-group
+ :label="label"
+ label-for="color-picker"
+ :description="description"
+ :invalid-feedback="this.$options.i18n.invalid"
+ :state="isValidColor"
+ :class="{ 'gl-mb-3!': hasSuggestedColors }"
+ >
+ <gl-form-input-group
+ id="color-picker"
+ :state="isValidColor"
+ max-length="7"
+ type="text"
+ class="gl-align-center gl-rounded-0 gl-rounded-top-right-base gl-rounded-bottom-right-base"
+ :value="selectedColor"
+ @input="handleColorChange"
+ >
+ <template #prepend>
+ <div :class="previewColorClasses" :style="previewColor" data-testid="color-preview">
+ <gl-form-input
+ type="color"
+ class="gl-absolute gl-top-0 gl-left-0 gl-h-full! gl-p-0! gl-m-0! gl-cursor-pointer gl-opacity-0"
+ tabindex="-1"
+ :value="selectedColor"
+ @input="handleColorChange"
+ />
+ </div>
+ </template>
+ </gl-form-input-group>
+ </gl-form-group>
+
+ <div v-if="hasSuggestedColors" class="gl-mb-3">
+ <gl-link
+ v-for="(name, hex) in suggestedColors"
+ :key="hex"
+ v-gl-tooltip
+ :title="name"
+ :style="{ backgroundColor: hex }"
+ class="gl-rounded-base gl-w-7 gl-h-7 gl-display-inline-block gl-mr-3 gl-mb-3 gl-text-decoration-none"
+ @click.prevent="handleColorChange(hex)"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
index 328c7e3fd32..eb7e24734ce 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
@@ -28,6 +28,8 @@ export default {
return {
width: 0,
height: 0,
+ renderedWidth: 0,
+ renderedHeight: 0,
};
},
computed: {
@@ -63,11 +65,14 @@ export default {
this.height = contentImg.naturalHeight;
this.$nextTick(() => {
+ this.renderedWidth = contentImg.clientWidth;
+ this.renderedHeight = contentImg.clientHeight;
+
this.$emit('imgLoaded', {
width: this.width,
height: this.height,
- renderedWidth: contentImg.clientWidth,
- renderedHeight: contentImg.clientHeight,
+ renderedWidth: this.renderedWidth,
+ renderedHeight: this.renderedHeight,
});
});
}
@@ -77,9 +82,14 @@ export default {
</script>
<template>
- <div>
+ <div data-testid="image-viewer">
<div :class="innerCssClasses" class="position-relative">
- <img ref="contentImg" :src="path" @load="onImgLoad" /> <slot name="image-overlay"></slot>
+ <img ref="contentImg" :src="path" @load="onImgLoad" />
+ <slot
+ name="image-overlay"
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ ></slot>
</div>
<p v-if="renderInfo" class="image-info">
<template v-if="hasFileSize">
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index 6bb05e59f6b..67be76604a3 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -109,7 +109,7 @@ export default {
</script>
<template>
- <div ref="markdownPreview" class="md-previewer">
+ <div ref="markdownPreview" class="md-previewer" data-testid="md-previewer">
<gl-skeleton-loading v-if="isLoading" />
<div v-else class="md" v-html="previewContent"></div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
index a7e6438a935..79cdf308ac5 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -219,7 +219,7 @@ export default {
<span v-if="utc" class="gl-text-gray-500 gl-font-weight-bold gl-font-sm">{{
__('UTC')
}}</span>
- <gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" />
+ <gl-icon class="gl-dropdown-caret" name="chevron-down" />
</template>
<div class="d-flex justify-content-between gl-p-2">
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
index 40708453d79..aaadc9766db 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker_lib.js
@@ -89,5 +89,3 @@ export const inputStringToIsoDate = (value, utc = false) => {
*/
export const isoDateToInputString = (date, utc = false) =>
dateformat(date, dateFormats.inputFormat, utc);
-
-export default {};
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
index a2fe19f9672..e755494a668 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
@@ -106,7 +106,13 @@ export default {
:a-mode="aMode"
:b-mode="bMode"
>
- <slot slot="image-overlay" name="image-overlay"></slot>
+ <template #image-overlay="{ renderedWidth, renderedHeight }">
+ <slot
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ name="image-overlay"
+ ></slot>
+ </template>
</component>
<slot></slot>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
index 2b5b2269ec8..433aafdeb9e 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
@@ -141,7 +141,13 @@ export default {
:path="newPath"
@imgLoaded="onionNewImgLoaded"
>
- <slot slot="image-overlay" name="image-overlay"> </slot>
+ <template #image-overlay="{ renderedWidth, renderedHeight }">
+ <slot
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ name="image-overlay"
+ ></slot>
+ </template>
</image-viewer>
</div>
<div class="controls">
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
index 2f2618d448f..acca6ba117f 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
@@ -143,7 +143,13 @@ export default {
class="frame added"
@imgLoaded="swipeNewImgLoaded"
>
- <slot slot="image-overlay" name="image-overlay"> </slot>
+ <template #image-overlay="{ renderedWidth, renderedHeight }">
+ <slot
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ name="image-overlay"
+ ></slot>
+ </template>
</image-viewer>
</div>
<span
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue
index 4dbfdb6d79c..97cac919b2a 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue
@@ -44,7 +44,13 @@ export default {
:inner-css-classes="['frame', 'added']"
class="wrap w-50"
>
- <slot slot="image-overlay" name="image-overlay"> </slot>
+ <template #image-overlay="{ renderedWidth, renderedHeight }">
+ <slot
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ name="image-overlay"
+ ></slot>
+ </template>
</image-viewer>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
index 6f5a133b225..00033145603 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
@@ -76,7 +76,13 @@ export default {
<div v-if="diffMode === $options.diffModes.replaced" class="diff-viewer">
<div class="image js-replaced-image">
<component :is="imageViewComponent" v-bind="$props">
- <slot slot="image-overlay" name="image-overlay"> </slot>
+ <template #image-overlay="{ renderedWidth, renderedHeight }">
+ <slot
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ name="image-overlay"
+ ></slot>
+ </template>
</component>
</div>
<div class="view-modes">
@@ -121,7 +127,13 @@ export default {
},
]"
>
- <slot v-if="isNew || isRenamed" slot="image-overlay" name="image-overlay"> </slot>
+ <template v-if="isNew || isRenamed" #image-overlay="{ renderedWidth, renderedHeight }">
+ <slot
+ :rendered-width="renderedWidth"
+ :rendered-height="renderedHeight"
+ name="image-overlay"
+ ></slot>
+ </template>
</image-viewer>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/dismissible_container.vue b/app/assets/javascripts/vue_shared/components/dismissible_container.vue
index b4227bae09e..6d5fd065751 100644
--- a/app/assets/javascripts/vue_shared/components/dismissible_container.vue
+++ b/app/assets/javascripts/vue_shared/components/dismissible_container.vue
@@ -45,7 +45,7 @@ export default {
data-testid="close"
@click="dismiss"
>
- <gl-icon name="close" aria-hidden="true" class="gl-text-gray-500" />
+ <gl-icon name="close" class="gl-text-gray-500" />
</button>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
index 48b94fdc181..edb5ffdc39c 100644
--- a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
+++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue
@@ -44,6 +44,6 @@ export default {
type="search"
autocomplete="off"
/>
- <gl-icon name="search" class="dropdown-input-search" aria-hidden="true" data-hidden="true" />
+ <gl-icon name="search" class="dropdown-input-search" data-hidden="true" />
</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 386df617d47..05403b38850 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -234,7 +234,6 @@ export default {
name="search"
class="dropdown-input-search"
:class="{ hidden: showClearInputButton }"
- aria-hidden="true"
/>
<gl-icon
name="close"
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index b4115b0c6a4..4d07d9fcfdd 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -143,6 +143,7 @@ export default {
:style="levelIndentation"
class="file-row-name"
data-qa-selector="file_name_content"
+ data-testid="file-row-name-container"
:class="[fileClasses, { 'str-truncated': !truncateMiddle, 'gl-min-w-0': truncateMiddle }]"
>
<file-icon
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 97b4ceda033..3988b3814f9 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
@@ -286,6 +286,7 @@ export default {
handleFilterSubmit() {
const filterTokens = uniqueTokens(this.filterValue);
this.filterValue = filterTokens;
+
if (this.recentSearchesStorageKey) {
this.recentSearchesPromise
.then(() => {
@@ -302,6 +303,17 @@ export default {
this.blurSearchInput();
this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens));
},
+ historyTokenOptionTitle(historyToken) {
+ const tokenOption = this.tokens
+ .find(token => token.type === historyToken.type)
+ ?.options?.find(option => option.value === historyToken.value.data);
+
+ if (!tokenOption?.title) {
+ return historyToken.value.data;
+ }
+
+ return tokenOption.title;
+ },
},
};
</script>
@@ -333,7 +345,7 @@ export default {
<span v-if="tokenTitles[token.type]"
>{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span
>
- <strong>{{ tokenSymbols[token.type] }}{{ token.value.data }}</strong>
+ <strong>{{ tokenSymbols[token.type] }}{{ historyTokenOptionTitle(token) }}</strong>
</span>
</template>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue
new file mode 100644
index 00000000000..1ad0ca36bf8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue
@@ -0,0 +1,97 @@
+<script>
+import Tribute from '@gitlab/tributejs';
+import {
+ GfmAutocompleteType,
+ tributeConfig,
+} from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import SidebarMediator from '~/sidebar/sidebar_mediator';
+
+export default {
+ errorMessage: __(
+ 'An error occurred while getting autocomplete data. Please refresh the page and try again.',
+ ),
+ props: {
+ autocompleteTypes: {
+ type: Array,
+ required: false,
+ default: () => Object.values(GfmAutocompleteType),
+ },
+ dataSources: {
+ type: Object,
+ required: false,
+ default: () => gl.GfmAutoComplete?.dataSources || {},
+ },
+ },
+ computed: {
+ config() {
+ return this.autocompleteTypes.map(type => ({
+ ...tributeConfig[type].config,
+ loadingItemTemplate: `<span class="gl-spinner gl-vertical-align-text-bottom gl-ml-3 gl-mr-2"></span>${__(
+ 'Loading',
+ )}`,
+ requireLeadingSpace: true,
+ values: this.getValues(type),
+ }));
+ },
+ },
+ mounted() {
+ this.cache = {};
+ this.tribute = new Tribute({ collection: this.config });
+
+ const input = this.$slots.default?.[0]?.elm;
+ this.tribute.attach(input);
+ },
+ beforeDestroy() {
+ const input = this.$slots.default?.[0]?.elm;
+ this.tribute.detach(input);
+ },
+ methods: {
+ cacheAssignees() {
+ const isAssigneesLengthSame =
+ this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length;
+
+ if (!this.assignees || !isAssigneesLengthSame) {
+ this.assignees =
+ SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
+ }
+ },
+ filterValues(type) {
+ // The assignees AJAX response can come after the user first invokes autocomplete
+ // so we need to check more than once if we need to update the assignee cache
+ this.cacheAssignees();
+
+ return tributeConfig[type].filterValues
+ ? tributeConfig[type].filterValues({
+ assignees: this.assignees,
+ collection: this.cache[type],
+ fullText: this.$slots.default?.[0]?.elm?.value,
+ selectionStart: this.$slots.default?.[0]?.elm?.selectionStart,
+ })
+ : this.cache[type];
+ },
+ getValues(type) {
+ return (inputText, processValues) => {
+ if (this.cache[type]) {
+ processValues(this.filterValues(type));
+ } else if (this.dataSources[type]) {
+ axios
+ .get(this.dataSources[type])
+ .then(response => {
+ this.cache[type] = response.data;
+ processValues(this.filterValues(type));
+ })
+ .catch(() => createFlash({ message: this.$options.errorMessage }));
+ } else {
+ processValues([]);
+ }
+ };
+ },
+ },
+ render(createElement) {
+ return createElement('div', this.$slots.default);
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js
new file mode 100644
index 00000000000..2581888b504
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js
@@ -0,0 +1,142 @@
+import { escape, last } from 'lodash';
+import { spriteIcon } from '~/lib/utils/common_utils';
+
+const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
+
+const nonWordOrInteger = /\W|^\d+$/;
+
+export const GfmAutocompleteType = {
+ Issues: 'issues',
+ Labels: 'labels',
+ Members: 'members',
+ MergeRequests: 'mergeRequests',
+ Milestones: 'milestones',
+ Snippets: 'snippets',
+};
+
+function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
+ const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
+ const currentLine = fullText.split('\n')[currentLineNumber - 1];
+ return currentLine.startsWith(searchString);
+}
+
+export const tributeConfig = {
+ [GfmAutocompleteType.Issues]: {
+ config: {
+ trigger: '#',
+ lookup: value => `${value.iid}${value.title}`,
+ menuItemTemplate: ({ original }) =>
+ `<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
+ selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
+ },
+ },
+
+ [GfmAutocompleteType.Labels]: {
+ config: {
+ trigger: '~',
+ lookup: 'title',
+ menuItemTemplate: ({ original }) => `
+ <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
+ ${escape(original.title)}`,
+ selectTemplate: ({ original }) =>
+ nonWordOrInteger.test(original.title)
+ ? `~"${escape(original.title)}"`
+ : `~${escape(original.title)}`,
+ },
+ filterValues({ collection, fullText, selectionStart }) {
+ if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
+ return collection.filter(label => !label.set);
+ }
+
+ if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
+ return collection.filter(label => label.set);
+ }
+
+ return collection;
+ },
+ },
+
+ [GfmAutocompleteType.Members]: {
+ config: {
+ trigger: '@',
+ fillAttr: 'username',
+ lookup: value =>
+ value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`,
+ menuItemTemplate: ({ original }) => {
+ const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0';
+ const noAvatarClasses = `${commonClasses} gl-rounded-small
+ gl-display-flex gl-align-items-center gl-justify-content-center`;
+
+ const avatar = original.avatar_url
+ ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
+ : `<div class="${noAvatarClasses}" aria-hidden="true">
+ ${original.username.charAt(0).toUpperCase()}</div>`;
+
+ let displayName = original.name;
+ let parentGroupOrUsername = `@${original.username}`;
+
+ if (original.type === groupType) {
+ const splitName = original.name.split(' / ');
+ displayName = splitName.pop();
+ parentGroupOrUsername = splitName.pop();
+ }
+
+ const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
+
+ const disabledMentionsIcon = original.mentionsDisabled
+ ? spriteIcon('notifications-off', 's16 gl-ml-3')
+ : '';
+
+ return `
+ <div class="gl-display-flex gl-align-items-center">
+ ${avatar}
+ <div class="gl-font-sm gl-line-height-normal gl-ml-3">
+ <div>${escape(displayName)}${count}</div>
+ <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
+ </div>
+ ${disabledMentionsIcon}
+ </div>
+ `;
+ },
+ },
+ filterValues({ assignees, collection, fullText, selectionStart }) {
+ if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
+ return collection.filter(member => !assignees.includes(member.username));
+ }
+
+ if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
+ return collection.filter(member => assignees.includes(member.username));
+ }
+
+ return collection;
+ },
+ },
+
+ [GfmAutocompleteType.MergeRequests]: {
+ config: {
+ trigger: '!',
+ lookup: value => `${value.iid}${value.title}`,
+ menuItemTemplate: ({ original }) =>
+ `<small>${original.reference || original.iid}</small> ${escape(original.title)}`,
+ selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
+ },
+ },
+
+ [GfmAutocompleteType.Milestones]: {
+ config: {
+ trigger: '%',
+ lookup: 'title',
+ menuItemTemplate: ({ original }) => escape(original.title),
+ selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
+ },
+ },
+
+ [GfmAutocompleteType.Snippets]: {
+ config: {
+ trigger: '$',
+ fillAttr: 'id',
+ lookup: value => `${value.id}${value.title}`,
+ menuItemTemplate: ({ original }) => `<small>${original.id}</small> ${escape(original.title)}`,
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
deleted file mode 100644
index dde7e3ebe13..00000000000
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ /dev/null
@@ -1,238 +0,0 @@
-<script>
-import { escape, last } from 'lodash';
-import Tribute from 'tributejs';
-import axios from '~/lib/utils/axios_utils';
-import { spriteIcon } from '~/lib/utils/common_utils';
-import SidebarMediator from '~/sidebar/sidebar_mediator';
-
-const AutoComplete = {
- Issues: 'issues',
- Labels: 'labels',
- Members: 'members',
- MergeRequests: 'mergeRequests',
- Milestones: 'milestones',
- Snippets: 'snippets',
-};
-
-const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings
-
-function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
- const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length;
- const currentLine = fullText.split('\n')[currentLineNumber - 1];
- return currentLine.startsWith(searchString);
-}
-
-const autoCompleteMap = {
- [AutoComplete.Issues]: {
- filterValues() {
- return this[AutoComplete.Issues];
- },
- menuItemTemplate({ original }) {
- return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`;
- },
- },
- [AutoComplete.Labels]: {
- filterValues() {
- const fullText = this.$slots.default?.[0]?.elm?.value;
- const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart;
-
- if (doesCurrentLineStartWith('/label', fullText, selectionStart)) {
- return this.labels.filter(label => !label.set);
- }
-
- if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) {
- return this.labels.filter(label => label.set);
- }
-
- return this.labels;
- },
- menuItemTemplate({ original }) {
- return `
- <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span>
- ${escape(original.title)}`;
- },
- },
- [AutoComplete.Members]: {
- filterValues() {
- const fullText = this.$slots.default?.[0]?.elm?.value;
- const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart;
-
- // Need to check whether sidebar store assignees has been updated
- // in the case where the assignees AJAX response comes after the user does @ autocomplete
- const isAssigneesLengthSame =
- this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length;
-
- if (!this.assignees || !isAssigneesLengthSame) {
- this.assignees =
- SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || [];
- }
-
- if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) {
- return this.members.filter(member => !this.assignees.includes(member.username));
- }
-
- if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) {
- return this.members.filter(member => this.assignees.includes(member.username));
- }
-
- return this.members;
- },
- menuItemTemplate({ original }) {
- const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0';
- const noAvatarClasses = `${commonClasses} gl-rounded-small
- gl-display-flex gl-align-items-center gl-justify-content-center`;
-
- const avatar = original.avatar_url
- ? `<img class="${commonClasses} gl-avatar-circle" src="${original.avatar_url}" alt="" />`
- : `<div class="${noAvatarClasses}" aria-hidden="true">
- ${original.username.charAt(0).toUpperCase()}</div>`;
-
- let displayName = original.name;
- let parentGroupOrUsername = `@${original.username}`;
-
- if (original.type === groupType) {
- const splitName = original.name.split(' / ');
- displayName = splitName.pop();
- parentGroupOrUsername = splitName.pop();
- }
-
- const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : '';
-
- const disabledMentionsIcon = original.mentionsDisabled
- ? spriteIcon('notifications-off', 's16 gl-ml-3')
- : '';
-
- return `
- <div class="gl-display-flex gl-align-items-center">
- ${avatar}
- <div class="gl-font-sm gl-line-height-normal gl-ml-3">
- <div>${escape(displayName)}${count}</div>
- <div class="gl-text-gray-700">${escape(parentGroupOrUsername)}</div>
- </div>
- ${disabledMentionsIcon}
- </div>
- `;
- },
- },
- [AutoComplete.MergeRequests]: {
- filterValues() {
- return this[AutoComplete.MergeRequests];
- },
- menuItemTemplate({ original }) {
- return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`;
- },
- },
- [AutoComplete.Milestones]: {
- filterValues() {
- return this[AutoComplete.Milestones];
- },
- menuItemTemplate({ original }) {
- return escape(original.title);
- },
- },
- [AutoComplete.Snippets]: {
- filterValues() {
- return this[AutoComplete.Snippets];
- },
- menuItemTemplate({ original }) {
- return `<small>${original.id}</small> ${escape(original.title)}`;
- },
- },
-};
-
-export default {
- name: 'GlMentions',
- props: {
- dataSources: {
- type: Object,
- required: false,
- default: () => gl.GfmAutoComplete?.dataSources || {},
- },
- },
- mounted() {
- const NON_WORD_OR_INTEGER = /\W|^\d+$/;
-
- this.tribute = new Tribute({
- collection: [
- {
- trigger: '#',
- lookup: value => value.iid + value.title,
- menuItemTemplate: autoCompleteMap[AutoComplete.Issues].menuItemTemplate,
- selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
- values: this.getValues(AutoComplete.Issues),
- },
- {
- trigger: '@',
- fillAttr: 'username',
- lookup: value =>
- value.type === groupType ? last(value.name.split(' / ')) : value.name + value.username,
- menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate,
- values: this.getValues(AutoComplete.Members),
- },
- {
- trigger: '~',
- lookup: 'title',
- menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate,
- selectTemplate: ({ original }) =>
- NON_WORD_OR_INTEGER.test(original.title)
- ? `~"${escape(original.title)}"`
- : `~${escape(original.title)}`,
- values: this.getValues(AutoComplete.Labels),
- },
- {
- trigger: '!',
- lookup: value => value.iid + value.title,
- menuItemTemplate: autoCompleteMap[AutoComplete.MergeRequests].menuItemTemplate,
- selectTemplate: ({ original }) => original.reference || `!${original.iid}`,
- values: this.getValues(AutoComplete.MergeRequests),
- },
- {
- trigger: '%',
- lookup: 'title',
- menuItemTemplate: autoCompleteMap[AutoComplete.Milestones].menuItemTemplate,
- selectTemplate: ({ original }) => `%"${escape(original.title)}"`,
- values: this.getValues(AutoComplete.Milestones),
- },
- {
- trigger: '$',
- fillAttr: 'id',
- lookup: value => value.id + value.title,
- menuItemTemplate: autoCompleteMap[AutoComplete.Snippets].menuItemTemplate,
- values: this.getValues(AutoComplete.Snippets),
- },
- ],
- });
-
- const input = this.$slots.default?.[0]?.elm;
- this.tribute.attach(input);
- },
- beforeDestroy() {
- const input = this.$slots.default?.[0]?.elm;
- this.tribute.detach(input);
- },
- methods: {
- getValues(autoCompleteType) {
- return (inputText, processValues) => {
- if (this[autoCompleteType]) {
- const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
- processValues(filteredValues);
- } else if (this.dataSources[autoCompleteType]) {
- axios
- .get(this.dataSources[autoCompleteType])
- .then(response => {
- this[autoCompleteType] = response.data;
- const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this);
- processValues(filteredValues);
- })
- .catch(() => {});
- } else {
- processValues([]);
- }
- };
- },
- },
- render(createElement) {
- return createElement('div', this.$slots.default);
- },
-};
-</script>
diff --git a/app/assets/javascripts/vue_shared/components/help_popover.vue b/app/assets/javascripts/vue_shared/components/help_popover.vue
index 7154360611f..821ae6cec52 100644
--- a/app/assets/javascripts/vue_shared/components/help_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/help_popover.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import { inserted } from '~/feature_highlight/feature_highlight_helper';
import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover';
@@ -11,7 +11,7 @@ import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover
export default {
name: 'HelpPopover',
components: {
- GlIcon,
+ GlButton,
},
props: {
options: {
@@ -43,7 +43,5 @@ export default {
};
</script>
<template>
- <button type="button" class="btn btn-blank btn-transparent btn-help" tabindex="0">
- <gl-icon name="question" />
- </button>
+ <gl-button variant="link" icon="question" tabindex="0" />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js
index 02f28da8bb0..61ab2a698ce 100644
--- a/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/vue_shared/components/lib/utils/dom_utils.js
@@ -1,5 +1,3 @@
export function pixeliseValue(val) {
return val ? `${val}px` : '';
}
-
-export default {};
diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue
deleted file mode 100644
index 59ce632c4a2..00000000000
--- a/app/assets/javascripts/vue_shared/components/loading_button.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-/* eslint-disable vue/require-default-prop */
-/*
-This component will be deprecated in favor of gl-deprecated-button.
-https://gitlab-org.gitlab.io/gitlab-ui/?path=/story/base-button--loading-button
-https://gitlab.com/gitlab-org/gitlab/issues/207412
-*/
-
-export default {
- components: {
- GlLoadingIcon,
- },
- props: {
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- disabled: {
- type: Boolean,
- required: false,
- default: false,
- },
- label: {
- type: String,
- required: false,
- },
- containerClass: {
- type: [String, Array, Object],
- required: false,
- default: 'btn btn-align-content',
- },
- },
- methods: {
- onClick(e) {
- this.$emit('click', e);
- },
- },
-};
-</script>
-
-<template>
- <button :class="containerClass" :disabled="loading || disabled" type="button" @click="onClick">
- <transition name="fade-in">
- <gl-loading-icon
- v-if="loading"
- :inline="true"
- :class="{
- 'gl-mr-2': label,
- }"
- class="js-loading-button-icon"
- />
- </transition>
- <transition name="fade-in">
- <slot>
- <span v-if="label" class="js-loading-button-label"> {{ label }} </span>
- </slot>
- </transition>
- </button>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
new file mode 100644
index 00000000000..b9729a3dc5c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/apply_suggestion.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+
+export default {
+ components: { GlDropdown, GlDropdownForm, GlFormTextarea, GlButton },
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ fileName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ message: null,
+ buttonText: __('Apply suggestion'),
+ headerText: __('Apply suggestion commit message'),
+ };
+ },
+ computed: {
+ placeholderText() {
+ return sprintf(__('Apply suggestion on %{fileName}'), { fileName: this.fileName });
+ },
+ },
+ methods: {
+ onApply() {
+ this.$emit('apply', this.message || this.placeholderText);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :text="buttonText"
+ :header-text="headerText"
+ :disabled="disabled"
+ boundary="window"
+ right
+ menu-class="gl-w-full! gl-pb-0!"
+ >
+ <gl-dropdown-form class="gl-m-3!">
+ <gl-form-textarea v-model="message" :placeholder="placeholderText" />
+ <gl-button
+ class="gl-w-quarter! gl-mt-3 gl-text-center! float-right"
+ category="secondary"
+ variant="success"
+ @click="onApply"
+ >
+ {{ __('Apply') }}
+ </gl-button>
+ </gl-dropdown-form>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 9cfba85e0d8..232a3054cd0 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -10,14 +10,14 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import GLForm from '~/gl_form';
import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue';
-import GlMentions from '~/vue_shared/components/gl_mentions.vue';
+import GfmAutocomplete from '~/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
- GlMentions,
+ GfmAutocomplete,
MarkdownHeader,
MarkdownToolbar,
GlIcon,
@@ -173,7 +173,7 @@ export default {
members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- epics: this.enableAutocomplete,
+ epics: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
milestones: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
snippets: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
@@ -246,9 +246,9 @@ export default {
/>
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
- <gl-mentions v-if="glFeatures.tributeAutocomplete">
+ <gfm-autocomplete v-if="glFeatures.tributeAutocomplete">
<slot name="textarea"></slot>
- </gl-mentions>
+ </gfm-autocomplete>
<slot v-else name="textarea"></slot>
<a
class="zen-control zen-control-leave js-zen-leave gl-text-gray-500"
diff --git a/app/assets/javascripts/vue_shared/components/members/utils.js b/app/assets/javascripts/vue_shared/components/members/utils.js
deleted file mode 100644
index 4229a62c0a7..00000000000
--- a/app/assets/javascripts/vue_shared/components/members/utils.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import { __ } from '~/locale';
-
-export const generateBadges = (member, isCurrentUser) => [
- {
- show: isCurrentUser,
- text: __("It's you"),
- variant: 'success',
- },
- {
- show: member.user?.blocked,
- text: __('Blocked'),
- variant: 'danger',
- },
- {
- show: member.user?.twoFactorEnabled,
- text: __('2FA'),
- variant: 'info',
- },
-];
-
-export const isGroup = member => {
- return Boolean(member.sharedWithGroup);
-};
-
-export const isDirectMember = (member, sourceId) => {
- return isGroup(member) || member.source?.id === sourceId;
-};
-
-export const isCurrentUser = (member, currentUserId) => {
- return member.user?.id === currentUserId;
-};
-
-export const canRemove = (member, sourceId) => {
- return isDirectMember(member, sourceId) && member.canRemove;
-};
-
-export const canResend = member => {
- return Boolean(member.invite?.canResend);
-};
-
-export const canUpdate = (member, currentUserId, sourceId) => {
- return (
- !isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate
- );
-};
-
-// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js`
-export const canOverride = () => false;
diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
index c12012d8419..ad6f6e0e2e3 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -88,7 +88,7 @@ export default {
};
</script>
<template>
- <div class="issuable-note-warning">
+ <div class="issuable-note-warning" data-testid="confidential-warning">
<gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue
index 8104d919bf6..85481f3f7b4 100644
--- a/app/assets/javascripts/vue_shared/components/pikaday.vue
+++ b/app/assets/javascripts/vue_shared/components/pikaday.vue
@@ -1,10 +1,14 @@
<script>
import Pikaday from 'pikaday';
+import { GlIcon } from '@gitlab/ui';
import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
export default {
name: 'DatePicker',
+ components: {
+ GlIcon,
+ },
props: {
label: {
type: String,
@@ -66,7 +70,7 @@ export default {
<div class="dropdown open">
<button type="button" class="dropdown-menu-toggle" data-toggle="dropdown" @click="toggled">
<span class="dropdown-toggle-text"> {{ label }} </span>
- <i class="fa fa-chevron-down" aria-hidden="true"> </i>
+ <gl-icon name="chevron-down" class="gl-absolute gl-right-3 gl-top-3 gl-text-gray-500" />
</button>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
index 9eacf74bba8..fe50a459e52 100644
--- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
@@ -105,6 +105,8 @@ export default {
registerHTMLToMarkdownRenderer(editorApi);
this.addListeners(editorApi);
+
+ this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() });
},
onOpenAddImageModal() {
this.$refs.addImageModal.show();
diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue
index c90bd4da6c2..3dbf0ccdfa9 100644
--- a/app/assets/javascripts/vue_shared/components/select2_select.vue
+++ b/app/assets/javascripts/vue_shared/components/select2_select.vue
@@ -1,6 +1,7 @@
<script>
import $ from 'jquery';
import 'select2';
+import { loadCSSFile } from '~/lib/utils/css_utils';
export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
@@ -20,10 +21,14 @@ export default {
},
mounted() {
- $(this.$refs.dropdownInput)
- .val(this.value)
- .select2(this.options)
- .on('change', event => this.$emit('input', event.target.value));
+ loadCSSFile(gon.select2_css_path)
+ .then(() => {
+ $(this.$refs.dropdownInput)
+ .val(this.value)
+ .select2(this.options)
+ .on('change', event => this.$emit('input', event.target.value));
+ })
+ .catch(() => {});
},
beforeDestroy() {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
index 7b2802650a2..4f505b9e678 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
@@ -16,7 +16,7 @@ export default {
type="button"
class="dropdown-title-button dropdown-menu-close gl-ml-auto"
>
- <gl-icon name="close" aria-hidden="true" class="dropdown-menu-close-icon" />
+ <gl-icon name="close" class="dropdown-menu-close-icon" />
</button>
</div>
</template>
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 2f71907f772..8ce624aa303 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
@@ -105,6 +105,11 @@ export default {
required: false,
default: __('Manage group labels'),
},
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -131,6 +136,11 @@ export default {
showDropdownContents(showDropdownContents) {
this.setContentIsOnViewport(showDropdownContents);
},
+ isEditing(newVal) {
+ if (newVal) {
+ this.toggleDropdownContents();
+ }
+ },
},
mounted() {
this.setInitialState({
diff --git a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
index 579ad53e6db..b48dfa8b452 100644
--- a/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
+++ b/app/assets/javascripts/vue_shared/components/tooltip_on_truncate.vue
@@ -1,6 +1,7 @@
<script>
import { isFunction } from 'lodash';
import tooltip from '../directives/tooltip';
+import { hasHorizontalOverflow } from '~/lib/utils/dom_utils';
export default {
directives: {
@@ -49,7 +50,7 @@ export default {
},
updateTooltip() {
const target = this.selectTarget();
- this.showTooltip = Boolean(target && target.scrollWidth > target.offsetWidth);
+ this.showTooltip = hasHorizontalOverflow(target);
},
},
};
diff --git a/app/assets/javascripts/vue_shared/directives/popover.js b/app/assets/javascripts/vue_shared/directives/popover.js
deleted file mode 100644
index c913bc34c68..00000000000
--- a/app/assets/javascripts/vue_shared/directives/popover.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import $ from 'jquery';
-
-/**
- * Helper to user bootstrap popover in vue.js.
- * Follow docs for html attributes: https://getbootstrap.com/docs/3.3/javascript/#static-popover
- *
- * @example
- * import popover from 'vue_shared/directives/popover.js';
- * {
- * directives: [popover]
- * }
- * <a v-popover="{options}">popover</a>
- */
-export default {
- bind(el, binding) {
- $(el).popover(binding.value);
- },
-
- unbind(el) {
- $(el).popover('dispose');
- },
-};
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/constants.js b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
new file mode 100644
index 00000000000..9b1cbfe218b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/components/constants.js
@@ -0,0 +1,8 @@
+export const SEVERITY_CLASS_NAME_MAP = {
+ critical: 'text-danger-800',
+ high: 'text-danger-600',
+ medium: 'text-warning-400',
+ low: 'text-warning-200',
+ info: 'text-primary-400',
+ unknown: 'text-secondary-400',
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
new file mode 100644
index 00000000000..3c606283c7d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/components/help_icon.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlButton, GlIcon, GlLink, GlPopover } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ GlLink,
+ GlPopover,
+ },
+ props: {
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ discoverProjectSecurityPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ i18n: {
+ securityReportsHelp: s__('SecurityReports|Security reports help page link'),
+ upgradeToManageVulnerabilities: s__('SecurityReports|Upgrade to manage vulnerabilities'),
+ upgradeToInteract: s__(
+ 'SecurityReports|Upgrade to interact, track and shift left with vulnerability management features in the UI.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <span v-if="discoverProjectSecurityPath">
+ <gl-button
+ ref="discoverProjectSecurity"
+ icon="information-o"
+ category="tertiary"
+ :aria-label="$options.i18n.upgradeToManageVulnerabilities"
+ />
+
+ <gl-popover
+ :target="() => $refs.discoverProjectSecurity.$el"
+ :title="$options.i18n.upgradeToManageVulnerabilities"
+ placement="top"
+ triggers="click blur"
+ >
+ {{ $options.i18n.upgradeToInteract }}
+ <gl-link :href="discoverProjectSecurityPath" target="_blank" class="gl-font-sm">{{
+ __('Learn more')
+ }}</gl-link>
+ </gl-popover>
+ </span>
+
+ <gl-link v-else target="_blank" :href="helpPath" :aria-label="$options.i18n.securityReportsHelp">
+ <gl-icon name="question" />
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue
new file mode 100644
index 00000000000..d7c1e27ff3e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/components/security_report_download_dropdown.vue
@@ -0,0 +1,48 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+export default {
+ name: 'SecurityReportDownloadDropdown',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ artifacts: {
+ type: Array,
+ required: true,
+ },
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ artifactText({ name }) {
+ return sprintf(s__('SecurityReports|Download %{artifactName}'), {
+ artifactName: name,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :text="s__('SecurityReports|Download results')"
+ :loading="loading"
+ icon="download"
+ right
+ >
+ <gl-dropdown-item
+ v-for="artifact in artifacts"
+ :key="artifact.path"
+ :href="artifact.path"
+ download
+ >
+ {{ artifactText(artifact) }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue
new file mode 100644
index 00000000000..babb9fddcf6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/components/security_summary.vue
@@ -0,0 +1,59 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { SEVERITY_CLASS_NAME_MAP } from './constants';
+
+export default {
+ components: {
+ GlSprintf,
+ },
+ props: {
+ message: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ shouldShowCountMessage() {
+ return !this.message.status && Boolean(this.message.countMessage);
+ },
+ },
+ methods: {
+ getSeverityClass(severity) {
+ return SEVERITY_CLASS_NAME_MAP[severity];
+ },
+ },
+ slotNames: ['critical', 'high', 'other'],
+ spacingClasses: {
+ critical: 'gl-pl-4',
+ high: 'gl-px-2',
+ other: 'gl-px-2',
+ },
+};
+</script>
+
+<template>
+ <span>
+ <gl-sprintf :message="message.message">
+ <template #total="{content}">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ <span v-if="shouldShowCountMessage" class="gl-font-sm">
+ <gl-sprintf :message="message.countMessage">
+ <template v-for="slotName in $options.slotNames" #[slotName]="{content}">
+ <span :key="slotName">
+ <strong
+ v-if="message[slotName] > 0"
+ :class="[getSeverityClass(slotName), $options.spacingClasses[slotName]]"
+ >
+ {{ content }}
+ </strong>
+ <span v-else :class="$options.spacingClasses[slotName]">
+ {{ content }}
+ </span>
+ </span>
+ </template>
+ </gl-sprintf>
+ </span>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index 2f87c4e7878..68241a8c5be 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -1,3 +1,32 @@
+import { invert } from 'lodash';
+
export const FEEDBACK_TYPE_DISMISSAL = 'dismissal';
export const FEEDBACK_TYPE_ISSUE = 'issue';
export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
+
+/**
+ * Security scan report types, as provided by the backend.
+ */
+export const REPORT_TYPE_SAST = 'sast';
+export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
+
+/**
+ * SecurityReportTypeEnum values for use with GraphQL.
+ *
+ * These should correspond to the lowercase security scan report types.
+ */
+export const SECURITY_REPORT_TYPE_ENUM_SAST = 'SAST';
+export const SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION = 'SECRET_DETECTION';
+
+/**
+ * A mapping from security scan report types to SecurityReportTypeEnum values.
+ */
+export const reportTypeToSecurityReportTypeEnum = {
+ [REPORT_TYPE_SAST]: SECURITY_REPORT_TYPE_ENUM_SAST,
+ [REPORT_TYPE_SECRET_DETECTION]: SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION,
+};
+
+/**
+ * A mapping from SecurityReportTypeEnum values to security scan report types.
+ */
+export const securityReportTypeEnumToReportType = invert(reportTypeToSecurityReportTypeEnum);
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
new file mode 100644
index 00000000000..310d8d88904
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/queries/security_report_download_paths.query.graphql
@@ -0,0 +1,23 @@
+query securityReportDownloadPaths(
+ $projectPath: ID!
+ $iid: String!
+ $reportTypes: [SecurityReportTypeEnum!]
+) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $iid) {
+ headPipeline {
+ jobs(securityReportTypes: $reportTypes) {
+ nodes {
+ name
+ artifacts {
+ nodes {
+ downloadPath
+ fileType
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
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 89253cc7116..bdbf9957ad4 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
@@ -1,19 +1,37 @@
<script>
-import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ReportSection from '~/reports/components/report_section.vue';
-import { status } from '~/reports/constants';
+import { LOADING, ERROR, SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR } from '~/reports/constants';
import { s__ } from '~/locale';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
-import Flash from '~/flash';
+import createFlash from '~/flash';
import Api from '~/api';
+import HelpIcon from './components/help_icon.vue';
+import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue';
+import SecuritySummary from './components/security_summary.vue';
+import store from './store';
+import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants';
+import {
+ REPORT_TYPE_SAST,
+ REPORT_TYPE_SECRET_DETECTION,
+ reportTypeToSecurityReportTypeEnum,
+} from './constants';
+import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql';
+import { extractSecurityReportArtifacts } from './utils';
export default {
+ store,
components: {
- GlIcon,
GlLink,
GlSprintf,
ReportSection,
+ HelpIcon,
+ SecurityReportDownloadDropdown,
+ SecuritySummary,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
pipelineId: {
type: Number,
@@ -27,33 +45,131 @@ export default {
type: String,
required: true,
},
+ discoverProjectSecurityPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ sastComparisonPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ secretScanningComparisonPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ targetProjectFullPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ mrIid: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ canDiscoverProjectSecurity: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
- hasSecurityReports: false,
+ availableSecurityReports: [],
+ canShowCounts: false,
- // Error state is shown even when successfully loaded, since success
+ // When core_security_mr_widget_counts is not enabled, the
+ // error state is shown even when successfully loaded, since success
// state suggests that the security scans detected no security problems,
// which is not necessarily the case. A future iteration will actually
// check whether problems were found and display the appropriate status.
- status: status.ERROR,
+ status: ERROR,
};
},
+ apollo: {
+ reportArtifacts: {
+ query: securityReportDownloadPathsQuery,
+ variables() {
+ return {
+ projectPath: this.targetProjectFullPath,
+ iid: String(this.mrIid),
+ reportTypes: this.$options.reportTypes.map(
+ reportType => reportTypeToSecurityReportTypeEnum[reportType],
+ ),
+ };
+ },
+ skip() {
+ return !this.canShowDownloads;
+ },
+ update(data) {
+ return extractSecurityReportArtifacts(this.$options.reportTypes, data);
+ },
+ error(error) {
+ this.showError(error);
+ },
+ result({ loading }) {
+ if (loading) {
+ return;
+ }
+
+ // Query has completed, so populate the availableSecurityReports.
+ this.onCheckingAvailableSecurityReports(
+ this.reportArtifacts.map(({ reportType }) => reportType),
+ );
+ },
+ },
+ },
+ computed: {
+ ...mapGetters(['groupedSummaryText', 'summaryStatus']),
+ canShowDownloads() {
+ return this.glFeatures.coreSecurityMrWidgetDownloads;
+ },
+ hasSecurityReports() {
+ return this.availableSecurityReports.length > 0;
+ },
+ hasSastReports() {
+ return this.availableSecurityReports.includes(REPORT_TYPE_SAST);
+ },
+ hasSecretDetectionReports() {
+ return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION);
+ },
+ isLoadingReportArtifacts() {
+ return this.$apollo.queries.reportArtifacts.loading;
+ },
+ shouldShowDownloadGuidance() {
+ return !this.canShowDownloads && this.summaryStatus !== LOADING;
+ },
+ scansHaveRunMessage() {
+ return this.canShowDownloads
+ ? this.$options.i18n.scansHaveRun
+ : this.$options.i18n.scansHaveRunWithDownloadGuidance;
+ },
+ },
created() {
- this.checkHasSecurityReports(this.$options.reportTypes)
- .then(hasSecurityReports => {
- this.hasSecurityReports = hasSecurityReports;
- })
- .catch(error => {
- Flash({
- message: this.$options.i18n.apiError,
- captureError: true,
- error,
- });
- });
+ if (!this.canShowDownloads) {
+ this.checkAvailableSecurityReports(this.$options.reportTypes)
+ .then(availableSecurityReports => {
+ this.onCheckingAvailableSecurityReports(Array.from(availableSecurityReports));
+ })
+ .catch(this.showError);
+ }
},
methods: {
- async checkHasSecurityReports(reportTypes) {
+ ...mapActions(MODULE_SAST, {
+ setSastDiffEndpoint: 'setDiffEndpoint',
+ fetchSastDiff: 'fetchDiff',
+ }),
+ ...mapActions(MODULE_SECRET_DETECTION, {
+ setSecretDetectionDiffEndpoint: 'setDiffEndpoint',
+ fetchSecretDetectionDiff: 'fetchDiff',
+ }),
+ async checkAvailableSecurityReports(reportTypes) {
+ const reportTypesSet = new Set(reportTypes);
+ const availableReportTypes = new Set();
+
let page = 1;
while (page) {
// eslint-disable-next-line no-await-in-loop
@@ -62,47 +178,127 @@ export default {
page,
});
- const hasSecurityReports = jobs.some(({ artifacts = [] }) =>
- artifacts.some(({ file_type }) => reportTypes.includes(file_type)),
- );
+ jobs.forEach(({ artifacts = [] }) => {
+ artifacts.forEach(({ file_type }) => {
+ if (reportTypesSet.has(file_type)) {
+ availableReportTypes.add(file_type);
+ }
+ });
+ });
- if (hasSecurityReports) {
- return true;
+ // If we've found artifacts for all the report types, stop looking!
+ if (availableReportTypes.size === reportTypesSet.size) {
+ return availableReportTypes;
}
page = parseIntPagination(normalizeHeaders(headers)).nextPage;
}
- return false;
+ return availableReportTypes;
+ },
+ fetchCounts() {
+ if (!this.glFeatures.coreSecurityMrWidgetCounts) {
+ return;
+ }
+
+ if (this.sastComparisonPath && this.hasSastReports) {
+ this.setSastDiffEndpoint(this.sastComparisonPath);
+ this.fetchSastDiff();
+ this.canShowCounts = true;
+ }
+
+ if (this.secretScanningComparisonPath && this.hasSecretDetectionReports) {
+ this.setSecretDetectionDiffEndpoint(this.secretScanningComparisonPath);
+ this.fetchSecretDetectionDiff();
+ this.canShowCounts = true;
+ }
},
activatePipelinesTab() {
if (window.mrTabs) {
window.mrTabs.tabShown('pipelines');
}
},
+ onCheckingAvailableSecurityReports(availableSecurityReports) {
+ this.availableSecurityReports = availableSecurityReports;
+ this.fetchCounts();
+ },
+ showError(error) {
+ createFlash({
+ message: this.$options.i18n.apiError,
+ captureError: true,
+ error,
+ });
+ },
},
- reportTypes: ['sast', 'secret_detection'],
+ reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION],
i18n: {
apiError: s__(
'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
),
- scansHaveRun: s__(
+ scansHaveRun: s__('SecurityReports|Security scans have run'),
+ scansHaveRunWithDownloadGuidance: s__(
'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
),
- securityReportsHelp: s__('SecurityReports|Security reports help page link'),
+ downloadFromPipelineTab: s__(
+ 'SecurityReports|Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
+ ),
},
+ summarySlots: [SLOT_SUCCESS, SLOT_LOADING, SLOT_ERROR],
};
</script>
<template>
<report-section
- v-if="hasSecurityReports"
+ v-if="canShowCounts"
+ :status="summaryStatus"
+ :has-issues="false"
+ class="mr-widget-border-top mr-report"
+ data-testid="security-mr-widget"
+ >
+ <template v-for="slot in $options.summarySlots" #[slot]>
+ <span :key="slot">
+ <security-summary :message="groupedSummaryText" />
+
+ <help-icon
+ :help-path="securityReportsDocsPath"
+ :discover-project-security-path="discoverProjectSecurityPath"
+ />
+ </span>
+ </template>
+
+ <template v-if="shouldShowDownloadGuidance" #sub-heading>
+ <span class="gl-font-sm">
+ <gl-sprintf :message="$options.i18n.downloadFromPipelineTab">
+ <template #link="{ content }">
+ <gl-link
+ class="gl-font-sm"
+ data-testid="show-pipelines"
+ @click="activatePipelinesTab"
+ >{{ content }}</gl-link
+ >
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+
+ <template v-if="canShowDownloads" #action-buttons>
+ <security-report-download-dropdown
+ :artifacts="reportArtifacts"
+ :loading="isLoadingReportArtifacts"
+ />
+ </template>
+ </report-section>
+
+ <!-- TODO: Remove this section when removing core_security_mr_widget_counts
+ feature flag. See https://gitlab.com/gitlab-org/gitlab/-/issues/284097 -->
+ <report-section
+ v-else-if="hasSecurityReports"
:status="status"
:has-issues="false"
class="mr-widget-border-top mr-report"
data-testid="security-mr-widget"
>
<template #error>
- <gl-sprintf :message="$options.i18n.scansHaveRun">
+ <gl-sprintf :message="scansHaveRunMessage">
<template #link="{ content }">
<gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{
content
@@ -110,14 +306,17 @@ export default {
</template>
</gl-sprintf>
- <gl-link
- target="_blank"
- data-testid="help"
- :href="securityReportsDocsPath"
- :aria-label="$options.i18n.securityReportsHelp"
- >
- <gl-icon name="question" />
- </gl-link>
+ <help-icon
+ :help-path="securityReportsDocsPath"
+ :discover-project-security-path="discoverProjectSecurityPath"
+ />
+ </template>
+
+ <template v-if="canShowDownloads" #action-buttons>
+ <security-report-download-dropdown
+ :artifacts="reportArtifacts"
+ :loading="isLoadingReportArtifacts"
+ />
</template>
</report-section>
</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/constants.js b/app/assets/javascripts/vue_shared/security_reports/store/constants.js
new file mode 100644
index 00000000000..6aeab56eea2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/constants.js
@@ -0,0 +1,7 @@
+/**
+ * Vuex module names corresponding to security scan types. These are similar to
+ * the snake_case report types from the backend, but should not be considered
+ * to be equivalent.
+ */
+export const MODULE_SAST = 'sast';
+export const MODULE_SECRET_DETECTION = 'secretDetection';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/getters.js b/app/assets/javascripts/vue_shared/security_reports/store/getters.js
new file mode 100644
index 00000000000..1e5a60c32fd
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/getters.js
@@ -0,0 +1,66 @@
+import { s__, sprintf } from '~/locale';
+import { countVulnerabilities, groupedTextBuilder } from './utils';
+import { LOADING, ERROR, SUCCESS } from '~/reports/constants';
+import { TRANSLATION_IS_LOADING } from './messages';
+
+export const summaryCounts = state =>
+ countVulnerabilities(
+ state.reportTypes.reduce((acc, reportType) => {
+ acc.push(...state[reportType].newIssues);
+ return acc;
+ }, []),
+ );
+
+export const groupedSummaryText = (state, getters) => {
+ const reportType = s__('ciReport|Security scanning');
+ let status = '';
+
+ // All reports are loading
+ if (getters.areAllReportsLoading) {
+ return { message: sprintf(TRANSLATION_IS_LOADING, { reportType }) };
+ }
+
+ // All reports returned error
+ if (getters.allReportsHaveError) {
+ return { message: s__('ciReport|Security scanning failed loading any results') };
+ }
+
+ if (getters.areReportsLoading && getters.anyReportHasError) {
+ status = s__('ciReport|is loading, errors when loading results');
+ } else if (getters.areReportsLoading && !getters.anyReportHasError) {
+ status = s__('ciReport|is loading');
+ } else if (!getters.areReportsLoading && getters.anyReportHasError) {
+ status = s__('ciReport|: Loading resulted in an error');
+ }
+
+ const { critical, high, other } = getters.summaryCounts;
+
+ return groupedTextBuilder({ reportType, status, critical, high, other });
+};
+
+export const summaryStatus = (state, getters) => {
+ if (getters.areReportsLoading) {
+ return LOADING;
+ }
+
+ if (getters.anyReportHasError || getters.anyReportHasIssues) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+export const areReportsLoading = state =>
+ state.reportTypes.some(reportType => state[reportType].isLoading);
+
+export const areAllReportsLoading = state =>
+ state.reportTypes.every(reportType => state[reportType].isLoading);
+
+export const allReportsHaveError = state =>
+ state.reportTypes.every(reportType => state[reportType].hasError);
+
+export const anyReportHasError = state =>
+ state.reportTypes.some(reportType => state[reportType].hasError);
+
+export const anyReportHasIssues = state =>
+ state.reportTypes.some(reportType => state[reportType].newIssues.length > 0);
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/index.js b/app/assets/javascripts/vue_shared/security_reports/store/index.js
new file mode 100644
index 00000000000..10705e04a21
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/index.js
@@ -0,0 +1,16 @@
+import Vuex from 'vuex';
+import * as getters from './getters';
+import state from './state';
+import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants';
+import sast from './modules/sast';
+import secretDetection from './modules/secret_detection';
+
+export default () =>
+ new Vuex.Store({
+ modules: {
+ [MODULE_SAST]: sast,
+ [MODULE_SECRET_DETECTION]: secretDetection,
+ },
+ getters,
+ state,
+ });
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/messages.js b/app/assets/javascripts/vue_shared/security_reports/store/messages.js
new file mode 100644
index 00000000000..c25e252a768
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/messages.js
@@ -0,0 +1,4 @@
+import { s__ } from '~/locale';
+
+export const TRANSLATION_IS_LOADING = s__('ciReport|%{reportType} is loading');
+export const TRANSLATION_HAS_ERROR = s__('ciReport|%{reportType}: Loading resulted in an error');
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/state.js b/app/assets/javascripts/vue_shared/security_reports/store/state.js
new file mode 100644
index 00000000000..5dc4d1ad2fb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/store/state.js
@@ -0,0 +1,5 @@
+import { MODULE_SAST, MODULE_SECRET_DETECTION } from './constants';
+
+export default () => ({
+ reportTypes: [MODULE_SAST, MODULE_SECRET_DETECTION],
+});
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/utils.js b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
index 6e50efae741..c5e786c92b1 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/utils.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/utils.js
@@ -1,5 +1,7 @@
import pollUntilComplete from '~/lib/utils/poll_until_complete';
import axios from '~/lib/utils/axios_utils';
+import { __, n__, sprintf } from '~/locale';
+import { CRITICAL, HIGH } from '~/vulnerabilities/constants';
import {
FEEDBACK_TYPE_DISMISSAL,
FEEDBACK_TYPE_ISSUE,
@@ -73,3 +75,79 @@ export const parseDiff = (diff, enrichData) => {
existing: diff.existing ? diff.existing.map(enrichVulnerability) : [],
};
};
+
+const createCountMessage = ({ critical, high, other, total }) => {
+ const otherMessage = n__('%d Other', '%d Others', other);
+ const countMessage = __(
+ '%{criticalStart}%{critical} Critical%{criticalEnd} %{highStart}%{high} High%{highEnd} and %{otherStart}%{otherMessage}%{otherEnd}',
+ );
+ return total ? sprintf(countMessage, { critical, high, otherMessage }) : '';
+};
+
+const createStatusMessage = ({ reportType, status, total }) => {
+ const vulnMessage = n__('vulnerability', 'vulnerabilities', total);
+ let message;
+ if (status) {
+ message = __('%{reportType} %{status}');
+ } else if (!total) {
+ message = __('%{reportType} detected %{totalStart}no%{totalEnd} vulnerabilities.');
+ } else {
+ message = __(
+ '%{reportType} detected %{totalStart}%{total}%{totalEnd} potential %{vulnMessage}',
+ );
+ }
+ return sprintf(message, { reportType, status, total, vulnMessage });
+};
+
+/**
+ * Counts vulnerabilities.
+ * Returns the amount of critical, high, and other vulnerabilities.
+ *
+ * @param {Array} vulnerabilities The raw vulnerabilities to parse
+ * @returns {{critical: number, high: number, other: number}}
+ */
+export const countVulnerabilities = (vulnerabilities = []) =>
+ vulnerabilities.reduce(
+ (acc, { severity }) => {
+ if (severity === CRITICAL) {
+ acc.critical += 1;
+ } else if (severity === HIGH) {
+ acc.high += 1;
+ } else {
+ acc.other += 1;
+ }
+
+ return acc;
+ },
+ { critical: 0, high: 0, other: 0 },
+ );
+
+/**
+ * Takes an object of options and returns the object with an externalized string representing
+ * the critical, high, and other severity vulnerabilities for a given report.
+ *
+ * The resulting string _may_ still contain sprintf-style placeholders. These
+ * are left in place so they can be replaced with markup, via the
+ * SecuritySummary component.
+ * @param {{reportType: string, status: string, critical: number, high: number, other: number}} options
+ * @returns {Object} the parameters with an externalized string
+ */
+export const groupedTextBuilder = ({
+ reportType = '',
+ status = '',
+ critical = 0,
+ high = 0,
+ other = 0,
+} = {}) => {
+ const total = critical + high + other;
+
+ return {
+ countMessage: createCountMessage({ critical, high, other, total }),
+ message: createStatusMessage({ reportType, status, total }),
+ critical,
+ high,
+ other,
+ status,
+ total,
+ };
+};
diff --git a/app/assets/javascripts/vue_shared/security_reports/utils.js b/app/assets/javascripts/vue_shared/security_reports/utils.js
new file mode 100644
index 00000000000..827a87f9aaf
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/security_reports/utils.js
@@ -0,0 +1,22 @@
+import { securityReportTypeEnumToReportType } from './constants';
+
+export const extractSecurityReportArtifacts = (reportTypes, data) => {
+ const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
+
+ return jobs.reduce((acc, job) => {
+ const artifacts = job.artifacts?.nodes ?? [];
+
+ artifacts.forEach(({ downloadPath, fileType }) => {
+ const reportType = securityReportTypeEnumToReportType[fileType];
+ if (reportType && reportTypes.includes(reportType)) {
+ acc.push({
+ name: job.name,
+ reportType,
+ path: downloadPath,
+ });
+ }
+ });
+
+ return acc;
+ }, []);
+};
diff --git a/app/assets/javascripts/vuex_shared/modules/members/index.js b/app/assets/javascripts/vuex_shared/modules/members/index.js
deleted file mode 100644
index 586d52a5288..00000000000
--- a/app/assets/javascripts/vuex_shared/modules/members/index.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import createState from 'ee_else_ce/vuex_shared/modules/members/state';
-import mutations from 'ee_else_ce/vuex_shared/modules/members/mutations';
-import * as actions from 'ee_else_ce/vuex_shared/modules/members/actions';
-
-export default initialState => ({
- namespaced: true,
- state: createState(initialState),
- actions,
- mutations,
-});
diff --git a/app/assets/javascripts/vulnerabilities/constants.js b/app/assets/javascripts/vulnerabilities/constants.js
new file mode 100644
index 00000000000..42fb38e8e7e
--- /dev/null
+++ b/app/assets/javascripts/vulnerabilities/constants.js
@@ -0,0 +1,15 @@
+/**
+ * Vulnerability severities as provided by the backend on vulnerability
+ * objects.
+ */
+export const CRITICAL = 'critical';
+export const HIGH = 'high';
+export const MEDIUM = 'medium';
+export const LOW = 'low';
+export const INFO = 'info';
+export const UNKNOWN = 'unknown';
+
+/**
+ * All vulnerability severities in decreasing order.
+ */
+export const SEVERITIES = [CRITICAL, HIGH, MEDIUM, LOW, INFO, UNKNOWN];
diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue
index 3c1de57252a..560cabd3bba 100644
--- a/app/assets/javascripts/whats_new/components/app.vue
+++ b/app/assets/javascripts/whats_new/components/app.vue
@@ -2,13 +2,15 @@
import { mapState, mapActions } from 'vuex';
import {
GlDrawer,
- GlBadge,
- GlIcon,
- GlLink,
GlInfiniteScroll,
GlResizeObserverDirective,
+ GlTabs,
+ GlTab,
+ GlBadge,
+ GlLoadingIcon,
} from '@gitlab/ui';
import SkeletonLoader from './skeleton_loader.vue';
+import Feature from './feature.vue';
import Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
@@ -17,11 +19,13 @@ const trackingMixin = Tracking.mixin();
export default {
components: {
GlDrawer,
- GlBadge,
- GlIcon,
- GlLink,
GlInfiniteScroll,
+ GlTabs,
+ GlTab,
SkeletonLoader,
+ Feature,
+ GlBadge,
+ GlLoadingIcon,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
@@ -31,11 +35,19 @@ export default {
storageKey: {
type: String,
required: true,
- default: null,
+ },
+ versions: {
+ type: Array,
+ required: true,
+ },
+ gitlabDotCom: {
+ type: Boolean,
+ required: false,
+ default: false,
},
},
computed: {
- ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight']),
+ ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']),
},
mounted() {
this.openDrawer(this.storageKey);
@@ -49,14 +61,25 @@ export default {
methods: {
...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']),
bottomReached() {
- if (this.pageInfo.nextPage) {
- this.fetchItems(this.pageInfo.nextPage);
+ const page = this.pageInfo.nextPage;
+ if (page) {
+ this.fetchItems({ page });
}
},
handleResize() {
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>
@@ -73,64 +96,39 @@ export default {
<template #header>
<h4 class="page-title gl-my-2">{{ __("What's new at GitLab") }}</h4>
</template>
- <gl-infinite-scroll
- v-if="features.length"
- :fetched-items="features.length"
- :max-list-height="drawerBodyHeight"
- class="gl-p-0"
- @bottomReached="bottomReached"
- >
- <template #items>
- <div
- v-for="feature in features"
- :key="feature.title"
- class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
+ <template v-if="features.length">
+ <gl-infinite-scroll
+ v-if="gitlabDotCom"
+ :fetched-items="features.length"
+ :max-list-height="drawerBodyHeight"
+ class="gl-p-0"
+ @bottomReached="bottomReached"
+ >
+ <template #items>
+ <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)"
>
- <gl-link
- :href="feature.url"
- target="_blank"
- class="whats-new-item-title-link"
- data-track-event="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
- >
- <h5 class="gl-font-lg">{{ feature.title }}</h5>
- </gl-link>
- <div v-if="feature.packages" class="gl-mb-3">
- <gl-badge
- v-for="package_name in feature.packages"
- :key="package_name"
- size="sm"
- class="whats-new-item-badge gl-mr-2"
- >
- <gl-icon name="license" />{{ package_name }}
- </gl-badge>
- </div>
- <gl-link
- :href="feature.url"
- target="_blank"
- data-track-event="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
- >
- <img
- :alt="feature.title"
- :src="feature.image_url"
- class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image"
+ <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"
/>
- </gl-link>
- <p class="gl-pt-3">{{ feature.body }}</p>
- <gl-link
- :href="feature.url"
- target="_blank"
- data-track-event="click_whats_new_item"
- :data-track-label="feature.title"
- :data-track-property="feature.url"
- >{{ __('Learn more') }}</gl-link
- >
- </div>
- </template>
- </gl-infinite-scroll>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+ </template>
<div v-else class="gl-mt-5">
<skeleton-loader />
<skeleton-loader />
diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue
new file mode 100644
index 00000000000..f6f7618b0d8
--- /dev/null
+++ b/app/assets/javascripts/whats_new/components/feature.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlBadge, GlIcon, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlBadge,
+ GlIcon,
+ GlLink,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ props: {
+ feature: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ class="whats-new-item-title-link"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >
+ <h5 class="gl-font-lg" data-test-id="feature-title">{{ feature.title }}</h5>
+ </gl-link>
+ <div v-if="feature.packages" class="gl-mb-3">
+ <gl-badge
+ v-for="packageName in feature.packages"
+ :key="packageName"
+ size="sm"
+ class="whats-new-item-badge gl-mr-2"
+ >
+ <gl-icon name="license" />{{ packageName }}
+ </gl-badge>
+ </div>
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >
+ <img
+ :alt="feature.title"
+ :src="feature.image_url"
+ class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image"
+ />
+ </gl-link>
+ <div v-safe-html="feature.body" class="gl-pt-3"></div>
+ <gl-link
+ :href="feature.url"
+ target="_blank"
+ data-track-event="click_whats_new_item"
+ :data-track-label="feature.title"
+ :data-track-property="feature.url"
+ >{{ __('Learn more') }}</gl-link
+ >
+ </div>
+</template>
diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js
index a57c9718156..ed0258c3992 100644
--- a/app/assets/javascripts/whats_new/index.js
+++ b/app/assets/javascripts/whats_new/index.js
@@ -1,25 +1,35 @@
import Vue from 'vue';
+import { mapState } from 'vuex';
import App from './components/app.vue';
import store from './store';
+import { getStorageKey, setNotification } from './utils/notification';
let whatsNewApp;
-export default () => {
+export default el => {
if (whatsNewApp) {
store.dispatch('openDrawer');
} else {
- const whatsNewElm = document.getElementById('whats-new-app');
-
whatsNewApp = new Vue({
- el: whatsNewElm,
+ el,
store,
components: {
App,
},
+ computed: {
+ ...mapState(['open']),
+ },
+ watch: {
+ open() {
+ setNotification(el);
+ },
+ },
render(createElement) {
return createElement('app', {
props: {
- storageKey: whatsNewElm.getAttribute('data-storage-key'),
+ storageKey: getStorageKey(el),
+ versions: JSON.parse(el.getAttribute('data-versions')),
+ gitlabDotCom: el.getAttribute('data-gitlab-dot-com'),
},
});
},
diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js
index 532febd61cb..0e5eeda742a 100644
--- a/app/assets/javascripts/whats_new/store/actions.js
+++ b/app/assets/javascripts/whats_new/store/actions.js
@@ -13,7 +13,7 @@ export default {
localStorage.setItem(storageKey, JSON.stringify(false));
}
},
- fetchItems({ commit, state }, page) {
+ fetchItems({ commit, state }, { page, version } = { page: null, version: null }) {
if (state.fetching) {
return false;
}
@@ -24,6 +24,7 @@ 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
new file mode 100644
index 00000000000..f261a089554
--- /dev/null
+++ b/app/assets/javascripts/whats_new/utils/notification.js
@@ -0,0 +1,17 @@
+export const getStorageKey = appEl => appEl.getAttribute('data-storage-key');
+
+export const setNotification = appEl => {
+ const storageKey = getStorageKey(appEl);
+ const notificationEl = document.querySelector('.header-help');
+ let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count');
+
+ if (JSON.parse(localStorage.getItem(storageKey)) === false) {
+ notificationEl.classList.remove('with-notifications');
+ if (notificationCountEl) {
+ notificationCountEl.parentElement.removeChild(notificationCountEl);
+ notificationCountEl = null;
+ }
+ } else {
+ notificationEl.classList.add('with-notifications');
+ }
+};
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index 52bc19fddd9..f56665553ba 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -10,7 +10,6 @@
@import './pages/events';
@import './pages/groups';
@import './pages/help';
-@import './pages/import';
@import './pages/incident_management_list';
@import './pages/issuable';
@import './pages/issues/issue_count_badge';
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 4b1139d2354..9ef1b58ed24 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -5,14 +5,10 @@
// directory.
@import '@gitlab/at.js/dist/css/jquery.atwho';
@import 'dropzone/dist/basic';
-@import 'select2';
// GitLab UI framework
@import 'framework';
-// Custom Fontawesome icons
-@import 'fontawesome_custom';
-
// Page specific styles (issues, projects etc):
@import 'page_specific_files';
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index 3d5076f485c..deeef86c386 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -235,10 +235,6 @@ h3.popover-header {
@extend .border-0;
}
- &.card-without-margin {
- margin: 0;
- }
-
&.bg-light {
@extend .border-0;
}
diff --git a/app/assets/stylesheets/components/milestone_combobox.scss b/app/assets/stylesheets/components/milestone_combobox.scss
index e0637088bbb..f73ec4d5998 100644
--- a/app/assets/stylesheets/components/milestone_combobox.scss
+++ b/app/assets/stylesheets/components/milestone_combobox.scss
@@ -1,11 +1,6 @@
-.selected-item::before {
- content: '\f00c';
- color: $green-500;
- position: absolute;
- left: 16px;
- top: 16px;
- transform: translateY(-50%);
- font: 14px FontAwesome;
+.selected-item {
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(asset_path('checkmark.png')) no-repeat 0 2px;
}
.dropdown-item-space {
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
deleted file mode 100644
index f870948cc4f..00000000000
--- a/app/assets/stylesheets/components/popover.scss
+++ /dev/null
@@ -1,111 +0,0 @@
-.popover {
- max-width: $popover-max-width;
- border: 1px solid $gray-100;
- box-shadow: $popover-box-shadow;
- font-size: $gl-font-size-small;
-
- /**
- * Blue popover variation
- */
- &.blue {
- background-color: $blue-600;
- border-color: $blue-600;
-
- .popover-body {
- color: $white;
- }
-
- &.bs-popover-bottom {
- .arrow::before,
- .arrow::after {
- border-bottom-color: $blue-600;
- }
- }
-
- &.bs-popover-top {
- .arrow::before,
- .arrow::after {
- border-top-color: $blue-600;
- }
- }
-
- &.bs-popover-right {
- .arrow::after,
- .arrow::before {
- border-right-color: $blue-600;
- }
- }
-
- &.bs-popover-left {
- .arrow::before,
- .arrow::after {
- border-left-color: $blue-600;
- }
- }
- }
-}
-
-.bs-popover-top {
- /* When popover position is top, the arrow is translated 1 pixel
- * due to the box-shadow include in our custom styles.
- */
- > .arrow::before {
- border-top-color: $gray-100;
- bottom: 1px;
- }
-
- > .arrow::after {
- bottom: 2px;
- }
-}
-
-.bs-popover-bottom {
- > .arrow::before {
- border-bottom-color: $gray-100;
- }
-
- > .popover-header::before {
- border-color: $white;
- }
-}
-
-.bs-popover-right > .arrow::before {
- border-right-color: $gray-100;
-}
-
-.bs-popover-left > .arrow::before {
- border-left-color: $gray-100;
-}
-
-.popover-header {
- background-color: $white;
- font-size: $gl-font-size-small;
-}
-
-.popover-body {
- padding: $gl-padding $gl-padding-12;
-
- > .popover-hr {
- margin: $gl-padding 0;
- }
-}
-
-/**
-* mr_popover component
-*/
-.mr-popover {
- .text-secondary {
- font-size: 12px;
- line-height: 1.33;
- }
-}
-
-.suggest-gitlab-ci-yml {
- margin-top: -1em;
-
- .popover-header {
- padding: $gl-padding;
- display: flex;
- align-items: center;
- }
-}
diff --git a/app/assets/stylesheets/components/whats_new.scss b/app/assets/stylesheets/components/whats_new.scss
index 64e82531c30..51bf2686be2 100644
--- a/app/assets/stylesheets/components/whats_new.scss
+++ b/app/assets/stylesheets/components/whats_new.scss
@@ -6,6 +6,32 @@
.gl-infinite-scroll-legend {
@include gl-display-none;
}
+
+ .gl-tabs {
+ @include gl-overflow-y-auto;
+ }
+
+ .gl-tabs-nav {
+ flex-wrap: nowrap;
+ overflow-x: scroll;
+ align-items: stretch;
+
+ .nav-item {
+ @include gl-flex-shrink-0;
+
+ a {
+ @include gl-h-full;
+ line-height: 1.5;
+ }
+ }
+ }
+
+ .gl-spinner-container {
+ @include gl-w-full;
+ @include gl-absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ }
}
.with-performance-bar .whats-new-drawer {
diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss
index 8a955cffc49..b9bb3edaaab 100644
--- a/app/assets/stylesheets/fontawesome_custom.scss
+++ b/app/assets/stylesheets/fontawesome_custom.scss
@@ -1,191 +1,43 @@
-/*!
- * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome
- * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
- */
-
-// stylelint-disable property-no-vendor-prefix
-// stylelint-disable at-rule-no-vendor-prefix
-// stylelint-disable stylelint-gitlab/duplicate-selectors
-// scss-lint:disable MergeableSelector
-@font-face {
- font-family: 'FontAwesome';
- src: asset-url('fontawesome-webfont.woff2?v=4.7.0') format('woff2'), asset-url('fontawesome-webfont.woff?v=4.7.0') format('woff');
- font-weight: normal;
- font-style: normal;
-}
-
-.fa {
- display: inline-block;
- font: normal normal normal 14px/1 FontAwesome;
- font-size: inherit;
- text-rendering: auto;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-/* makes the font 33% larger relative to the icon container */
-.fa-lg {
- font-size: 1.33333333em;
- line-height: 0.75em;
- vertical-align: -15%;
-}
-
-.fa-2x {
- font-size: 2em;
-}
-
-.fa-3x {
- font-size: 3em;
-}
-
-.fa-4x {
- font-size: 4em;
-}
-
-.fa-5x {
- font-size: 5em;
-}
-
-.fa-fw {
- width: 1.28571429em;
- text-align: center;
-}
-
-.fa-spin {
- -webkit-animation: fa-spin 2s infinite linear;
- animation: fa-spin 2s infinite linear;
-}
-
-@-webkit-keyframes fa-spin {
- 0% {
- -webkit-transform: rotate(0deg);
- transform: rotate(0deg);
+// 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;
}
- 100% {
- -webkit-transform: rotate(359deg);
- transform: rotate(359deg);
+ .fa-2x {
+ font-size: 2em;
}
-}
-@keyframes fa-spin {
- 0% {
- -webkit-transform: rotate(0deg);
- transform: rotate(0deg);
+ .fa-exclamation-triangle::before {
+ content: '⚠';
}
- 100% {
- -webkit-transform: rotate(359deg);
- transform: rotate(359deg);
+ .fa-exclamation-circle::before {
+ content: '❗';
}
-}
-
-.fa-inverse {
- color: $white;
-}
-
-.fa-chevron-down::before {
- content: '\f078';
-}
-
-.fa-caret-down::before {
- content: '\f0d7';
-}
-
-.fa-warning::before,
-.fa-exclamation-triangle::before {
- content: '\f071';
-}
-
-.fa-spinner::before {
- content: '\f110';
-}
-
-.fa-caret-right::before {
- content: '\f0da';
-}
-
-.fa-exclamation-circle::before {
- content: '\f06a';
-}
-
-.fa-file-o::before {
- content: '\f016';
-}
-
-.fa-lightbulb-o::before {
- content: '\f0eb';
-}
-
-.fa-circle::before {
- content: '\f111';
-}
-
-.fa-thumb-tack::before {
- content: '\f08d';
-}
-
-.fa-fire::before {
- content: '\f06d';
-}
-
-.fa-file-pdf-o::before {
- content: '\f1c1';
-}
-
-.fa-file-word-o::before {
- content: '\f1c2';
-}
-
-.fa-file-excel-o::before {
- content: '\f1c3';
-}
-.fa-file-powerpoint-o::before {
- content: '\f1c4';
-}
-
-.fa-file-image-o::before {
- content: '\f1c5';
-}
-
-.fa-file-archive-o::before {
- content: '\f1c6';
-}
-
-.fa-file-audio-o::before {
- content: '\f1c7';
-}
-
-.fa-file-video-o::before {
- content: '\f1c8';
-}
+ .fa-lightbulb-o::before {
+ content: '💡';
+ }
-.fa-square-o::before {
- content: '\f096';
-}
+ .fa-thumb-tack::before {
+ content: '📌';
+ }
-.fa-check-square-o::before {
- content: '\f046';
-}
+ .fa-fire::before {
+ content: '🔥';
+ }
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- border: 0;
-}
+ .fa-square-o::before {
+ content: '\2610';
+ }
-.sr-only-focusable:active,
-.sr-only-focusable:focus {
- position: static;
- width: auto;
- height: auto;
- margin: 0;
- overflow: visible;
- clip: auto;
+ .fa-check-square-o::before {
+ content: '\2611';
+ }
}
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 196fb3a7088..a93c70c75d3 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -103,7 +103,8 @@
@include transition(color);
}
-a {
+a,
+.notification-dot {
@include transition(background-color, color, border);
}
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 4f09f1a394b..d9ad4992458 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -253,3 +253,111 @@
vertical-align: middle;
}
}
+
+
+// The following encompasses the "add reaction" button redesign to
+// align properly within GitLab UI's gl-button. The implementation
+// above will be deprecated once all instances of "award emoji" are
+// migrated to Vue.
+
+.gl-button .award-emoji-block gl-emoji {
+ top: -1px;
+ margin-top: -1px;
+ margin-bottom: -1px;
+}
+
+.add-reaction-button {
+ position: relative;
+
+ // This forces the height and width of the inner content to match
+ // other gl-buttons despite all child elements being set to
+ // `position:absolute`
+ &::after {
+ content: '\a0';
+ width: 1em;
+ }
+
+ .reaction-control-icon {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+
+ // center the icon vertically and horizontally within the button
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ @include transition(opacity, transform);
+
+ .gl-icon {
+ height: $default-icon-size;
+ width: $default-icon-size;
+ }
+ }
+
+ .reaction-control-icon-neutral {
+ opacity: 1;
+ }
+
+ .reaction-control-icon-positive,
+ .reaction-control-icon-super-positive {
+ opacity: 0;
+ }
+
+ &:hover,
+ &.active,
+ &:active,
+ &.is-active {
+ // extra specificty added to override another selector
+ .reaction-control-icon .gl-icon {
+ color: $blue-500;
+ transform: scale(1.15);
+ }
+
+ .reaction-control-icon-neutral {
+ opacity: 0;
+ }
+ }
+
+ &:hover {
+ .reaction-control-icon-positive {
+ opacity: 1;
+ }
+ }
+
+ &.active,
+ &:active,
+ &.is-active {
+ .reaction-control-icon-positive {
+ opacity: 0;
+ }
+
+ .reaction-control-icon-super-positive {
+ opacity: 1;
+ }
+ }
+
+ &.disabled {
+ cursor: default;
+
+ &:hover,
+ &:focus,
+ &:active {
+ .reaction-control-icon .gl-icon {
+ color: inherit;
+ transform: scale(1);
+ }
+
+ .reaction-control-icon-neutral {
+ opacity: 1;
+ }
+
+ .reaction-control-icon-positive,
+ .reaction-control-icon-super-positive {
+ opacity: 0;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index f42e500efa8..bfa4a640fe2 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -73,7 +73,7 @@
&.content-component-block {
padding: 11px 0;
- background-color: $white;
+ background-color: $body-bg;
}
.title {
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index a8cc685d880..182c58c3931 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -166,8 +166,7 @@
line-height: $gl-btn-xs-line-height;
}
- &.btn-success,
- &.btn-register {
+ &.btn-success {
@include btn-green;
}
@@ -176,7 +175,6 @@
@include btn-outline($white, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800);
}
- &.btn-remove,
&.btn-danger {
@include btn-outline($white, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
}
@@ -200,18 +198,11 @@
@include btn-orange;
}
- &.btn-close,
- &.btn-close-color {
+ &.btn-close {
@include btn-outline($white, $orange-500, $orange-500, $orange-50, $orange-600, $orange-600, $orange-100, $orange-700, $orange-700);
}
- &.btn-spam {
- @include btn-outline($white, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
- }
-
- &.btn-danger,
- &.btn-remove,
- &.btn-red {
+ &.btn-danger {
@include btn-red;
}
@@ -219,11 +210,6 @@
float: right;
}
- &.btn-reopen,
- .btn-reopen-color {
- /* should be same as parent class for now */
- }
-
&.btn-grouped {
@include btn-with-margin;
}
@@ -232,17 +218,6 @@
color: $gray-700;
}
- .fa-caret-down,
- .fa-chevron-down {
- margin-left: 5px;
- }
-
- &.dropdown-toggle {
- .fa-caret-down {
- margin-left: 3px;
- }
- }
-
&.btn-text-field {
width: 100%;
text-align: left;
@@ -276,11 +251,8 @@
width: 15px;
}
- svg,
- .fa {
- &:not(:last-child) {
- margin-right: 5px;
- }
+ svg:not(:last-child) {
+ margin-right: 5px;
}
}
@@ -370,24 +342,15 @@
.btn-loading {
&:not(.disabled) {
- .fa,
.spinner {
display: none;
}
}
-
- .fa {
- margin-right: 5px;
- }
}
.btn-build {
margin-left: 10px;
- i {
- color: $gl-text-color-secondary;
- }
-
svg {
fill: $gl-text-color-secondary;
}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index deb2d6c4641..3b59c028437 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -135,7 +135,6 @@ hr {
text-overflow: ellipsis;
white-space: nowrap;
- > div:not(.block):not(.select2-display-none),
.str-truncated {
display: inline;
}
@@ -389,11 +388,7 @@ img.emoji {
🚨 Do not use these classes — they are deprecated and being removed. 🚨
See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details.
**/
-.prepend-top-15 { margin-top: 15px; }
.prepend-top-20 { margin-top: 20px; }
-.prepend-left-15 { margin-left: 15px; }
-.prepend-left-20 { margin-left: 20px; }
-.append-right-20 { margin-right: 20px; }
.append-bottom-20 { margin-bottom: 20px; }
.ml-10 { margin-left: 4.5rem; }
.inline { display: inline-block; }
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index e16ab5ee72f..cf9363b77be 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -2,10 +2,6 @@
.diff-file {
margin-bottom: $gl-padding;
- &.conflict {
- border-top: 1px solid $border-color;
- }
-
&.has-body {
.file-title {
box-shadow: 0 -2px 0 0 var(--white);
@@ -60,7 +56,7 @@
left: -11px;
width: 10px;
height: calc(100% + 1px);
- background: $white;
+ background: $body-bg;
pointer-events: none;
}
@@ -601,10 +597,6 @@ table.code {
.diff-grid-right {
display: grid;
grid-template-columns: 50px 8px 1fr;
-
- .diff-td:nth-child(2) {
- display: none;
- }
}
.diff-grid-comments {
@@ -635,20 +627,6 @@ table.code {
.diff-grid-left,
.diff-grid-right {
grid-template-columns: 50px 50px 8px 1fr;
-
- .diff-td:nth-child(2) {
- display: block;
- }
- }
-
- .diff-grid-left .old:nth-child(1) [data-linenumber],
- .diff-grid-right .new:nth-child(2) [data-linenumber] {
- display: inline;
- }
-
- .diff-grid-left .old:nth-child(2) [data-linenumber],
- .diff-grid-right .new:nth-child(1) [data-linenumber] {
- display: none;
}
}
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 2094c824286..e2335c184b0 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -16,12 +16,6 @@
}
}
-@mixin chevron-active {
- .fa-chevron-down {
- color: $gray-darkest;
- }
-}
-
@mixin set-visible {
transform: translateY(0);
display: block;
@@ -56,7 +50,6 @@
.dropdown-toggle,
.dropdown-menu-toggle {
- @include chevron-active;
border-color: $gray-darkest;
}
@@ -114,20 +107,11 @@
color: $gray-darkest;
}
- .fa-chevron-down {
- font-size: $dropdown-chevron-size;
- position: relative;
- top: -2px;
- margin-left: 5px;
- }
-
&:hover {
- @include chevron-active;
border-color: $gray-darkest;
}
&:focus:active {
- @include chevron-active;
border-color: $dropdown-toggle-active-border-color;
outline: 0;
}
@@ -143,18 +127,6 @@
.fa {
position: absolute;
-
- &.fa-spinner {
- font-size: 16px;
- margin-top: -3px;
- }
- }
-
- .fa-chevron-down,
- .fa-spinner {
- position: absolute;
- top: 11px;
- right: 8px;
}
.spinner {
@@ -369,7 +341,8 @@
}
.droplab-dropdown {
- .dropdown-toggle > i {
+ .dropdown-toggle > i,
+ .dropdown-toggle > svg {
pointer-events: none;
}
@@ -532,29 +505,27 @@
&.is-active {
color: $gl-text-color;
- &::before {
- position: absolute;
- left: 16px;
- top: 16px;
- transform: translateY(-50%);
- font: normal normal normal 14px/1 FontAwesome;
- font-size: inherit;
- text-rendering: auto;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
-
&.dropdown-menu-user-link::before {
top: 50%;
}
}
&.is-indeterminate::before {
- content: '\f068';
+ position: absolute;
+ left: 16px;
+ top: 16px;
+ transform: translateY(-50%);
+ font-style: normal;
+ font-size: inherit;
+ text-rendering: auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ content: '—';
}
- &.is-active::before {
- content: '\f00c';
+ &.is-active {
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(asset_path('checkmark.png')) no-repeat 14px 8px;
}
}
}
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index 7be676ed83c..6e47fef02d5 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -133,11 +133,6 @@ label {
}
.input-group {
- .select2-container {
- display: table-cell;
- max-width: 180px;
- }
-
.input-group-prepend,
.input-group-append {
background-color: $input-group-addon-bg;
@@ -213,15 +208,6 @@ label {
position: relative;
}
-.select-wrapper > .fa-chevron-down {
- position: absolute;
- font-size: 10px;
- right: 10px;
- top: 12px;
- color: $gray-darkest;
- pointer-events: none;
-}
-
.input-icon-wrapper > .input-icon-right {
position: absolute;
right: 0.8em;
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 52319d9658b..a6a01c7b090 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -384,6 +384,10 @@
text-overflow: ellipsis;
flex: 0 1 auto;
}
+
+ &:last-of-type > .breadcrumbs-list-angle {
+ display: none;
+ }
}
}
@@ -556,12 +560,17 @@
border: 1px solid $gray-normal;
}
-.header-user-notification-dot {
+.notification-dot {
background-color: $orange-300;
height: 12px;
width: 12px;
- right: 8px;
- top: -8px;
+ margin-top: -15px;
+ pointer-events: none;
+ visibility: hidden;
+}
+
+.with-notifications .notification-dot {
+ visibility: visible;
}
.with-performance-bar .navbar-gitlab {
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 2464ea3607b..b0334da6943 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -234,6 +234,8 @@ ul.content-list {
}
}
+ul.content-list.issuable-list > li,
+ul.content-list.todos-list > li,
.card > .content-list > li {
padding: $gl-padding-top $gl-padding;
}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 20d44b71bf6..7ba9236b833 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -146,7 +146,11 @@
}
@mixin green-status-color {
- @include status-color($green-100, $green-500, $green-700);
+ @include status-color(
+ var(--green-100, $green-100),
+ var(--green-500, $green-500),
+ var(--green-700, $green-700)
+ );
}
@mixin fade($gradient-direction, $gradient-color) {
@@ -169,7 +173,6 @@
transition-duration: 0.3s;
}
- .fa,
svg {
position: relative;
top: 5px;
@@ -255,9 +258,9 @@
@mixin build-trace-bar($height) {
height: $height;
min-height: $height;
- background: $gray-light;
- border: 1px solid $border-color;
- color: $gl-text-color;
+ background: var(--gray-50, $gray-50);
+ border: 1px solid var(--border-color, $border-color);
+ color: var(--gl-text-color, $gl-text-color);
padding: $grid-size;
}
@@ -361,11 +364,6 @@
color: $gray-400;
fill: $gray-400;
- .fa {
- position: relative;
- font-size: 16px;
- }
-
svg {
@include btn-svg;
margin: 0;
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 372e3bed6e0..2dbeacb0f8c 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -91,6 +91,7 @@
body.modal-open {
overflow: hidden;
+ padding-right: 0 !important;
}
.modal-no-backdrop {
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 3e218de6af9..2ad9a9d2dff 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -276,7 +276,7 @@
@include fade(left, $gray-light);
right: -5px;
- .fa {
+ svg {
right: -7px;
}
}
@@ -286,7 +286,7 @@
left: -5px;
text-align: center;
- .fa {
+ svg {
left: -7px;
}
}
@@ -337,7 +337,7 @@
@include fade(left, $white);
right: -5px;
- .fa {
+ svg {
right: -7px;
}
}
@@ -346,7 +346,7 @@
@include fade(right, $white);
left: -5px;
- .fa {
+ svg {
left: -7px;
}
}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 86a5aa1a16e..d8ce6826fc1 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -1,275 +1,3 @@
-/** Select2 selectbox style override **/
-.select2-container {
- width: 100% !important;
-
- &.input-md,
- &.input-lg {
- display: block;
- }
-}
-
-.select2-container,
-.select2-container.select2-drop-above {
- .select2-choice {
- background: $white;
- color: $gl-text-color;
- border-color: $input-border;
- height: 34px;
- padding: $gl-vert-padding $gl-input-padding;
- font-size: $gl-font-size;
- line-height: 1.42857143;
- border-radius: $border-radius-base;
-
- .select2-arrow {
- background-image: none;
- background-color: transparent;
- border: 0;
- padding-top: 12px;
- padding-right: 20px;
- font-size: 10px;
-
- b {
- display: none;
- }
-
- &::after {
- content: '\f078';
- position: absolute;
- z-index: 1;
- text-align: center;
- pointer-events: none;
- box-sizing: border-box;
- color: $gray-darkest;
- display: inline-block;
- font: normal normal normal 14px/1 FontAwesome;
- font-size: inherit;
- text-rendering: auto;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
- }
-
- .select2-chosen {
- margin-right: 15px;
- }
-
- &:hover {
- border-color: $gray-darkest;
- color: $gl-text-color;
- }
- }
-
- // Essentially we’re doing @include form-control-focus here (from
- // bootstrap/scss/mixins/_forms.scss), except that the bootstrap mixin adds a
- // `&:focus` selector and we’re never actually focusing the .select2-choice
- // link nor the .select2-container, the Select2 library focuses an off-screen
- // .select2-focusser element instead.
- &.select2-container-active:not(.select2-dropdown-open) {
- .select2-choice {
- color: $input-focus-color;
- background-color: $input-focus-bg;
- border-color: $input-focus-border-color;
- outline: 0;
- }
-
- // Reusable focus “glow” box-shadow
- @mixin form-control-focus-glow {
- @if $enable-shadows {
- box-shadow: $input-box-shadow, $input-focus-box-shadow;
- } @else {
- box-shadow: $input-focus-box-shadow;
- }
- }
-
- // Apply the focus “glow” shadow to the .select2-container if it also has
- // the .block-truncated class as that applies an overflow: hidden, thereby
- // hiding the glow of the nested .select2-choice element.
- &.block-truncated {
- @include form-control-focus-glow;
- }
-
- // Apply the glow directly to the .select2-choice link if we’re not
- // block-truncating the container.
- &:not(.block-truncated) .select2-choice {
- @include form-control-focus-glow;
- }
- }
-
- &.is-invalid {
- ~ .invalid-feedback {
- display: block;
- }
-
- .select2-choices,
- .select2-choice {
- border-color: $red-500;
- }
- }
-}
-
-.select2-drop,
-.select2-drop.select2-drop-above {
- background: $white;
- box-shadow: 0 2px 4px $dropdown-shadow-color;
- border-radius: $border-radius-base;
- border: 1px solid $border-color;
- min-width: 175px;
- color: $gl-text-color;
- z-index: 999;
-
- .modal-open & {
- z-index: $zindex-modal + 200;
- }
-}
-
-.select2-drop-mask {
- z-index: 998;
-
- .modal-open & {
- z-index: $zindex-modal + 100;
- }
-}
-
-.select2-drop.select2-drop-above.select2-drop-active {
- border-top: 1px solid $border-color;
- margin-top: -6px;
-}
-
-.select2-container-active {
- .select2-choice,
- .select2-choices {
- box-shadow: none;
- }
-}
-
-.select2-dropdown-open,
-.select2-dropdown-open.select2-drop-above {
- .select2-choice {
- border-color: $gray-darkest;
- outline: 0;
- }
-}
-
-.select2-container-multi {
- .select2-choices {
- border-radius: $border-radius-default;
- border-color: $input-border;
- background: none;
-
- .select2-search-field input {
- padding: 5px $gl-input-padding;
- height: auto;
- font-family: inherit;
- font-size: inherit;
- }
-
- .select2-search-choice {
- margin: 5px 0 0 8px;
- box-shadow: none;
- border-color: $input-border;
- color: $gl-text-color;
- line-height: 15px;
- background-color: $gray-light;
- background-image: none;
- padding: 3px 18px 3px 5px;
-
- .select2-search-choice-close {
- top: 5px;
- left: initial;
- right: 3px;
- }
-
- &.select2-search-choice-focus {
- border-color: $gl-text-color;
- }
- }
- }
-}
-
-.select2-drop-active {
- margin-top: $dropdown-vertical-offset;
- font-size: 14px;
-
- .select2-results {
- max-height: 350px;
- }
-}
-
-.select2-search {
- padding: $grid-size;
-
- .select2-drop-auto-width & {
- padding: $grid-size;
- }
-
- input {
- padding: $grid-size;
- background: transparent image-url('select2.png');
- color: $gl-text-color;
- background-clip: content-box;
- background-origin: content-box;
- background-repeat: no-repeat;
- background-position: right 0 bottom 0 !important;
- border: 1px solid $input-border;
- border-radius: $border-radius-default;
- line-height: 16px;
- transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
-
- &:focus {
- border-color: $blue-300;
- }
-
- &.select2-active {
- background-color: $white;
- background-image: image-url('select2-spinner.gif') !important;
- background-origin: content-box;
- background-repeat: no-repeat;
- background-position: right 6px center !important;
- background-size: 16px 16px !important;
- }
- }
-
- + .select2-results {
- padding-top: 0;
- }
-}
-
-.select2-results {
- margin: 0;
- padding: #{$gl-padding / 2} 0;
-
- .select2-no-results,
- .select2-searching,
- .select2-ajax-error,
- .select2-selection-limit {
- background: transparent;
- padding: #{$gl-padding / 2} $gl-padding;
- }
-
- .select2-result-label,
- .select2-more-results {
- padding: #{$gl-padding / 2} $gl-padding;
- }
-
- .select2-highlighted {
- background: transparent;
- color: $gl-text-color;
-
- .select2-result-label {
- background: $gray-darker;
- }
- }
-
- .select2-result {
- padding: 0 1px;
- }
-
- li.select2-result-with-children > .select2-result-label {
- font-weight: $gl-font-weight-bold;
- color: $gl-text-color;
- }
-}
-
.ajax-users-select {
width: 400px;
@@ -282,14 +10,6 @@
}
}
-.select2-highlighted {
- .group-result {
- .group-path {
- color: $gray-700;
- }
- }
-}
-
.group-result {
.group-image {
float: left;
@@ -345,11 +65,3 @@
.ajax-users-dropdown {
min-width: 250px !important;
}
-
-.select2-result-selectable,
-.select2-result-unselectable {
- .select2-match {
- font-weight: $gl-font-weight-bold;
- text-decoration: none;
- }
-}
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 39d9e9a77f9..89713fdbbea 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -184,46 +184,3 @@ table {
border-top: 0;
}
}
-
-.vulnerability-list {
- @media (min-width: $breakpoint-sm) {
- .checkbox {
- padding-left: $gl-spacing-scale-4;
- padding-right: 0;
- width: 1px;
-
- + td,
- + th {
- padding-left: $gl-spacing-scale-4;
- }
- }
-
- .detected {
- width: 9%;
- }
-
- .status {
- width: 8%;
- }
-
- .severity {
- width: 10%;
- }
-
- .description {
- max-width: 0;
- }
-
- .identifier {
- width: 16%;
- }
-
- .scanner {
- width: 10%;
- }
-
- .activity {
- width: 5%;
- }
- }
-}
diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss
index 054280f3321..fd888fdec65 100644
--- a/app/assets/stylesheets/framework/toggle.scss
+++ b/app/assets/stylesheets/framework/toggle.scss
@@ -4,22 +4,22 @@
* @usage
* ### Active and Inactive text should be provided as data attributes:
* <button type="button" class="project-feature-toggle" data-enabled-text="Enabled" data-disabled-text="Disabled">
-* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span>
* </button>
* ### Checked should have `is-checked` class
* <button type="button" class="project-feature-toggle is-checked" data-enabled-text="Enabled" data-disabled-text="Disabled">
-* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span>
* </button>
* ### Disabled should have `is-disabled` class
* <button type="button" class="project-feature-toggle is-disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true">
-* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
+* <span class="gl-spinner loading-icon hidden" aria-label="Loading"></span>
* </button>
* ### Loading should have `is-loading` and an icon with `loading-icon` class
* <button type="button" class="project-feature-toggle is-loading" data-enabled-text="Enabled" data-disabled-text="Disabled">
-* <i class="fa fa-spinner fa-spin loading-icon"></i>
+* <span class="gl-spinner loading-icon" aria-label="Loading"></span>
* </button>
*/
.project-feature-toggle {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 3d09edfe181..1a568bb41a5 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -1,3 +1,6 @@
+// Custom Fontawesome icons
+@import 'fontawesome_custom';
+
/**
* Apply Markup (Markdown/AsciiDoc) typography
*
@@ -432,11 +435,11 @@
&::before {
margin-right: 4px;
- font: normal normal normal 14px/1 FontAwesome;
+ font-style: normal;
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
- content: '\f0c6';
+ content: '📎';
}
&.no-attachment-icon {
@@ -573,10 +576,6 @@ body {
font-size: 1.25em;
font-weight: $gl-font-weight-bold;
- &:last-child {
- margin-bottom: 0;
- }
-
&.with-button {
line-height: 34px;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index f0b1e859139..808813599c5 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -468,7 +468,6 @@ $gl-line-height-20: 20px;
$gl-line-height-24: 24px;
$gl-line-height-14: 14px;
-$issue-box-upcoming-bg: #8f8f8f;
$pages-group-name-color: #4c4e54;
/*
diff --git a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
index 6c51c4b0ec3..b148cc8f0e7 100644
--- a/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
+++ b/app/assets/stylesheets/lazy_bundles/select2_overrides.scss
@@ -22,32 +22,14 @@
border-radius: $gl-border-radius-base;
.select2-arrow {
- background-image: none;
- background-color: transparent;
- border: 0;
padding-top: 12px;
padding-right: 20px;
- font-size: 10px;
+ /* stylelint-disable-next-line function-url-quotes */
+ background: url(asset_path('chevron-down.png')) no-repeat 2px 8px;
b {
display: none;
}
-
- &::after {
- content: '\f078';
- position: absolute;
- z-index: 1;
- text-align: center;
- pointer-events: none;
- box-sizing: border-box;
- color: $gray-darkest;
- display: inline-block;
- font: normal normal normal 14px/1 FontAwesome;
- font-size: inherit;
- text-rendering: auto;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
- }
}
.select2-chosen {
diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
index 499394ad960..cc876c9a635 100644
--- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
+++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
@@ -4,7 +4,7 @@
position: absolute;
top: 48%;
left: -$length;
- border-top: 2px solid $border-color;
+ border-top: 2px solid var(--border-color, $border-color);
width: $length;
height: 1px;
}
@@ -14,14 +14,14 @@
display: inline-block;
padding: 8px 10px 9px;
width: 100%;
- border: 1px solid $border-color;
+ border: 1px solid var(--border-color, $border-color);
border-radius: $border-radius;
- background-color: $white;
+ background-color: var(--white, $white);
&:hover {
- background-color: $gray-darker;
+ background-color: var(--gray-50, $gray-50);
border: 1px solid $dropdown-toggle-active-border-color;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
@@ -66,7 +66,7 @@
@mixin mini-pipeline-item() {
border-radius: 100px;
- background-color: $white;
+ background-color: var(--white, $white);
border-width: 1px;
border-style: solid;
width: $ci-action-icon-size;
@@ -85,22 +85,22 @@
// Dropdown button animation in mini pipeline graph
&.ci-status-icon-success {
- @include mini-pipeline-graph-color($white, $green-100, $green-200, $green-500, $green-600, $green-700);
+ @include mini-pipeline-graph-color(var(--white, $white), $green-100, $green-200, $green-500, $green-600, $green-700);
}
&.ci-status-icon-failed {
- @include mini-pipeline-graph-color($white, $red-100, $red-200, $red-500, $red-600, $red-700);
+ @include mini-pipeline-graph-color(var(--white, $white), $red-100, $red-200, $red-500, $red-600, $red-700);
}
&.ci-status-icon-pending,
&.ci-status-icon-waiting-for-resource,
&.ci-status-icon-success-with-warnings {
- @include mini-pipeline-graph-color($white, $orange-50, $orange-100, $orange-500, $orange-600, $orange-700);
+ @include mini-pipeline-graph-color(var(--white, $white), $orange-50, $orange-100, $orange-500, $orange-600, $orange-700);
}
&.ci-status-icon-preparing,
&.ci-status-icon-running {
- @include mini-pipeline-graph-color($white, $blue-100, $blue-200, $blue-500, $blue-600, $blue-700);
+ @include mini-pipeline-graph-color(var(--white, $white), $blue-100, $blue-200, $blue-500, $blue-600, $blue-700);
}
&.ci-status-icon-canceled,
@@ -108,12 +108,12 @@
&.ci-status-icon-disabled,
&.ci-status-icon-not-found,
&.ci-status-icon-manual {
- @include mini-pipeline-graph-color($white, $gray-500, $gray-700, $gray-900, $gray-950, $black);
+ @include mini-pipeline-graph-color(var(--white, $white), $gray-500, $gray-700, $gray-900, $gray-950, $black);
}
&.ci-status-icon-created,
&.ci-status-icon-skipped {
- @include mini-pipeline-graph-color($white, $gray-100, $gray-200, $gray-300, $gray-400, $gray-500);
+ @include mini-pipeline-graph-color(var(--white, $white), $gray-100, $gray-200, $gray-300, $gray-400, $gray-500);
}
}
@@ -226,7 +226,7 @@
&:focus {
outline: none;
text-decoration: none;
- background-color: $gray-darker;
+ background-color: var(--gray-100, $gray-50);
}
}
}
diff --git a/app/assets/stylesheets/page_bundles/alert_management_details.scss b/app/assets/stylesheets/page_bundles/alert_management_details.scss
index beb80a14c5a..2eaf4517710 100644
--- a/app/assets/stylesheets/page_bundles/alert_management_details.scss
+++ b/app/assets/stylesheets/page_bundles/alert_management_details.scss
@@ -17,22 +17,19 @@
}
}
- .assignee-dropdown-item {
- .dropdown-item {
- @include gl-display-flex;
- @include gl-align-items-center;
-
+ .dropdown-item {
+ &:first-child {
&::before {
- top: 50% !important;
+ @include gl-pt-0;
}
+ }
- &.is-active {
- &:last-child {
- @include gl-border-b-gray-100;
- @include gl-border-b-1;
- @include gl-border-b-solid;
- }
- }
+ &::before {
+ @include gl-pt-8;
+ }
+
+ .gl-new-dropdown-item-text-wrapper {
+ @include gl-py-0;
}
}
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index ffc15af6329..3d1ae3519a9 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -92,7 +92,6 @@
.board-title-caret {
border-radius: $border-radius-default;
line-height: $gl-spacing-scale-5;
- height: $gl-spacing-scale-5;
&.btn svg {
top: 0;
@@ -173,13 +172,6 @@
}
}
-.board-promotion-state {
- background-color: var(--white, $white);
- flex: 1;
- overflow-y: auto;
- overflow-x: hidden;
-}
-
.board-list-component {
min-height: 0; // firefox fix
}
diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss
index 2f0f4a46658..3962c546b51 100644
--- a/app/assets/stylesheets/page_bundles/build.scss
+++ b/app/assets/stylesheets/page_bundles/build.scss
@@ -61,7 +61,7 @@
}
.environment-information {
- border: 1px solid $border-color;
+ border: 1px solid var(--border-color, $border-color);
padding: 8px $gl-padding 12px;
border-radius: $border-radius-default;
@@ -219,9 +219,9 @@
}
.builds-container {
- background-color: $white;
- border-top: 1px solid $border-color;
- border-bottom: 1px solid $border-color;
+ background-color: var(--white, $white);
+ border-top: 1px solid var(--border-color, $border-color);
+ border-bottom: 1px solid var(--border-color, $border-color);
max-height: 300px;
width: 289px;
overflow: auto;
@@ -237,7 +237,7 @@
width: 270px;
&:hover {
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
}
@@ -256,13 +256,13 @@
}
&:hover {
- background-color: $gray-darker;
+ background-color: var(--gray-50, $gray-50);
}
}
}
.link-commit {
- color: $blue-600;
+ color: var(--blue-600, $blue-600);
}
}
diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss
index 8522a0a8fe4..232d363b7f1 100644
--- a/app/assets/stylesheets/page_bundles/ci_status.scss
+++ b/app/assets/stylesheets/page_bundles/ci_status.scss
@@ -2,7 +2,7 @@
.ci-status {
padding: 2px 7px 4px;
- border: 1px solid $gray-darker;
+ border: 1px solid var(--border-color, $border-color);
white-space: nowrap;
border-radius: 4px;
@@ -18,7 +18,11 @@
}
&.ci-failed {
- @include status-color($red-100, $red-500, $red-600);
+ @include status-color(
+ var(--red-100, $red-100),
+ var(--red-500, $red-500),
+ var(--red-600, $red-600)
+ );
}
&.ci-success {
@@ -26,11 +30,12 @@
}
&.ci-canceled,
+ &.ci-skipped,
&.ci-disabled,
&.ci-scheduled,
&.ci-manual {
- color: $gl-text-color;
- border-color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
+ border-color: currentColor;
&:not(span):hover {
background-color: rgba($gl-text-color, 0.07);
@@ -38,25 +43,37 @@
}
&.ci-preparing {
- @include status-color($gray-100, $gray-300, $gray-400);
+ @include status-color(
+ var(--gray-100, $gray-100),
+ var(--gray-300, $gray-300),
+ var(--gray-400, $gray-400)
+ );
}
&.ci-pending,
&.ci-waiting-for-resource,
&.ci-failed-with-warnings,
&.ci-success-with-warnings {
- @include status-color($orange-50, $orange-500, $orange-700);
+ @include status-color(
+ var(--orange-50, $orange-50),
+ var(--orange-500, $orange-500),
+ var(--orange-700, $orange-700)
+ );
}
&.ci-info,
&.ci-running {
- @include status-color($blue-100, $blue-500, $blue-600);
+ @include status-color(
+ var(--blue-100, $blue-100),
+ var(--blue-500, $blue-500),
+ var(--blue-600, $blue-600)
+ );
}
&.ci-created,
&.ci-skipped {
- color: $gl-text-color-secondary;
- border-color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
+ border-color: currentColor;
&:not(span):hover {
background-color: rgba($gl-text-color-secondary, 0.07);
diff --git a/app/assets/stylesheets/page_bundles/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
index 3a5e2e4159d..4a48333cd27 100644
--- a/app/assets/stylesheets/page_bundles/cycle_analytics.scss
+++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
@@ -314,11 +314,6 @@
vertical-align: top;
font-weight: $gl-font-weight-normal;
}
-
- .fa {
- color: var(--gray-500, $gray-500);
- font-size: $code-font-size;
- }
}
}
diff --git a/app/assets/stylesheets/page_bundles/import.scss b/app/assets/stylesheets/page_bundles/import.scss
new file mode 100644
index 00000000000..5f43d5df7e3
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/import.scss
@@ -0,0 +1,81 @@
+@import 'mixins_and_variables_and_functions';
+
+.import-jobs-to-col {
+ width: 39%;
+}
+
+.import-jobs-status-col {
+ width: 15%;
+}
+
+.import-jobs-cta-col {
+ width: 1%;
+}
+
+.import-project-name-input {
+ border-radius: 0 $border-radius-default $border-radius-default 0;
+ position: relative;
+ left: -1px;
+ max-width: 300px;
+}
+
+.import-slash-divider {
+ background-color: $gray-lightest;
+ border: 1px solid $border-color;
+}
+
+.import-row {
+ height: 55px;
+}
+
+.import-table {
+ .import-jobs-from-col,
+ .import-jobs-to-col,
+ .import-jobs-status-col,
+ .import-jobs-cta-col {
+ border-bottom-width: 1px;
+ padding-left: $gl-padding;
+ }
+}
+
+.import-projects-loading-icon {
+ margin-top: $gl-padding-32;
+}
+
+.import-entities-target-select {
+ &.disabled {
+ .import-entities-target-select-separator,
+ .select2-container.select2-container-disabled .select2-choice {
+ color: var(--gray-400, $gray-400);
+ border-color: var(--gray-100, $gray-100);
+ background-color: var(--gray-10, $gray-10);
+ }
+
+ .select2-container.select2-container-disabled .select2-choice .select2-arrow {
+ background-color: var(--gray-10, $gray-10);
+ }
+ }
+
+ .import-entities-target-select-separator {
+ border-color: var(--gray-200, $gray-200);
+ background-color: var(--gray-10, $gray-10);
+ }
+
+ .select2-container {
+ > .select2-choice {
+ .select2-arrow {
+ background-color: var(--white, $white);
+ }
+
+ border-color: var(--gray-200, $gray-200);
+ color: var(--gray-900, $gray-900) !important;
+ background-color: var(--white, $white) !important;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ }
+
+ .gl-form-input {
+ box-shadow: inset 0 0 0 1px var(--gray-200, $gray-200);
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/merge_conflicts.scss b/app/assets/stylesheets/page_bundles/merge_conflicts.scss
index b0655408edf..a26affb10a9 100644
--- a/app/assets/stylesheets/page_bundles/merge_conflicts.scss
+++ b/app/assets/stylesheets/page_bundles/merge_conflicts.scss
@@ -255,10 +255,6 @@ $colors: (
}
}
- .btn-success .fa-spinner {
- color: var(--white, $white);
- }
-
.editor-wrap {
&.is-loading {
.editor {
diff --git a/app/assets/stylesheets/page_bundles/oncall_schedules.scss b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
new file mode 100644
index 00000000000..3c95ecc9bf0
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/oncall_schedules.scss
@@ -0,0 +1,189 @@
+@import 'mixins_and_variables_and_functions';
+
+@mixin inset-border-1-red-500($important: false) {
+ box-shadow: inset 0 0 0 $gl-border-size-1 $red-500 if-important($important);
+}
+
+.timezone-dropdown {
+ .dropdown-menu {
+ @include gl-w-full;
+ }
+
+ .gl-new-dropdown-item-text-primary {
+ @include gl-overflow-hidden;
+ @include gl-text-overflow-ellipsis;
+ }
+}
+
+.modal-footer {
+ @include gl-bg-gray-10;
+}
+
+.invalid-dropdown {
+ .gl-dropdown-toggle {
+ @include inset-border-1-red-500;
+
+ &:hover {
+ @include inset-border-1-red-500(true);
+ }
+ }
+}
+
+//// Copied from roadmaps.scss - adapted for on-call schedules
+$header-item-height: 72px;
+$item-height: 40px;
+$details-cell-width: 180px;
+$timeline-cell-height: 32px;
+$timeline-cell-width: 180px;
+$border-style: 1px solid var(--gray-100, $gray-100);
+$gradient-dark-gray: rgba(0, 0, 0, 0.15);
+$gradient-gray: rgba(255, 255, 255, 0.001);
+$scroll-top-gradient: linear-gradient(to bottom, $gradient-dark-gray 0%, $gradient-gray 100%);
+$scroll-bottom-gradient: linear-gradient(to bottom, $gradient-gray 0%, $gradient-dark-gray 100%);
+$column-right-gradient: linear-gradient(to right, $gradient-dark-gray 0%, $gradient-gray 100%);
+
+.schedule-shell {
+ @include gl-relative;
+ @include gl-h-full;
+ @include gl-w-full;
+ @include gl-overflow-x-auto;
+ @include gl-border-gray-100;
+ @include gl-border-1;
+ @include gl-border-solid;
+ @include gl-rounded-base;
+}
+
+.timeline-section {
+ @include gl-sticky;
+ @include gl-top-0;
+ z-index: 20;
+
+ .timeline-header-blank,
+ .timeline-header-item {
+ @include float-left;
+ height: $header-item-height;
+ border-bottom: $border-style;
+ background-color: var(--white, $white);
+ }
+
+ .timeline-header-blank {
+ @include gl-sticky;
+ @include gl-top-0;
+ @include gl-left-0;
+ width: $details-cell-width;
+ z-index: 2;
+ }
+
+ .timeline-header-item {
+ // container size minus left panel width divided by 2 week timeframes
+ width: calc((100% - #{$details-cell-width}) / 2);
+
+ &:last-of-type .item-label {
+ @include gl-border-r-0;
+ }
+
+ .item-label,
+ .item-sublabel .sublabel-value {
+ color: var(--gray-400, $gray-400);
+ @include gl-font-weight-normal;
+
+ &.label-dark {
+ @include gl-text-gray-900;
+ }
+
+ &.label-bold {
+ @include gl-font-weight-bold;
+ }
+ }
+
+ .item-label {
+ @include gl-py-4;
+ @include gl-pl-4;
+ border-right: $border-style;
+ border-bottom: $border-style;
+ }
+
+ .item-sublabel {
+ @include gl-relative;
+ @include gl-display-flex;
+
+ .sublabel-value {
+ @include gl-flex-grow-1;
+ @include gl-flex-basis-0;
+
+ text-align: center;
+ @include gl-font-base;
+ padding: 2px 0;
+ }
+ }
+
+ .current-day-indicator-header {
+ @include gl-absolute;
+ @include gl-bottom-0;
+ height: $grid-size;
+ width: $grid-size;
+ background-color: var(--red-500, $red-500);
+ @include gl-rounded-full;
+ transform: translate(-50%, 50%);
+ }
+ }
+}
+
+.timeline-section .timeline-header-blank,
+.list-section .details-cell {
+ &::after {
+ @include gl-h-full;
+ @include gl-content-empty;
+ @include gl-absolute;
+ @include gl-top-0;
+ right: -$grid-size;
+ width: $grid-size;
+ @include gl-pointer-events-none;
+ background: $column-right-gradient;
+ }
+}
+
+.details-cell,
+.timeline-cell {
+ @include float-left;
+ height: $item-height;
+}
+
+.details-cell {
+ @include gl-sticky;
+ @include gl-left-0;
+ width: $details-cell-width;
+ @include gl-font-base;
+ background-color: var(--white, $white);
+ z-index: 10;
+}
+
+.timeline-cell {
+ @include gl-relative;
+ // width: $timeline-cell-width;
+ // container size minus left panel width divided by 2 week timeframes
+ width: calc((100% - #{$details-cell-width}) / 2);
+ @include gl-bg-transparent;
+ border-right: $border-style;
+
+ &:last-child {
+ @include gl-border-r-0;
+ }
+
+ .current-day-indicator {
+ @include gl-absolute;
+ top: -1px;
+ width: $gl-spacing-scale-1;
+ height: calc(100% + 1px);
+ background-color: var(--red-500, $red-500);
+ @include gl-pointer-events-none;
+ transform: translateX(-50%);
+ }
+}
+
+.gl-token {
+ .gl-avatar-labeled-label {
+ @include gl-text-white;
+ @include gl-font-weight-normal;
+ }
+}
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index 1de66aa73da..d9ab52774bd 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -33,7 +33,7 @@
}
.stage {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
font-weight: $gl-font-weight-normal;
vertical-align: middle;
}
@@ -62,7 +62,7 @@
a {
font-weight: $gl-font-weight-bold;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
text-decoration: none;
&:focus,
@@ -124,11 +124,46 @@
display: flex;
width: 100%;
min-height: $dropdown-max-height-lg;
- background-color: $gray-light;
+ background-color: var(--gray-50, $gray-50);
padding: $gl-padding 0;
overflow: auto;
}
+// These are single-value classes to use with utility-class style CSS
+// but to still access this variable. Do not add other styles.
+.gl-pipeline-min-h {
+ min-height: $dropdown-max-height-lg;
+}
+
+.gl-pipeline-job-width {
+ width: 186px;
+}
+
+.gl-linked-pipeline-padding {
+ padding-right: 120px;
+}
+
+.gl-build-content {
+ @include build-content();
+}
+
+.gl-ci-action-icon-container {
+ position: absolute;
+ right: 5px;
+ top: 50% !important;
+ transform: translateY(-50%);
+
+ // Action Icons in big pipeline-graph nodes
+ &.ci-action-icon-wrapper {
+ height: 30px;
+ width: 30px;
+ border-radius: 100%;
+ display: block;
+ padding: 0;
+ line-height: 0;
+ }
+}
+
// Pipeline graph, used at
// app/assets/javascripts/pipelines/components/graph/graph_component.vue
.pipeline-graph {
@@ -142,7 +177,7 @@
a {
text-decoration: none;
- color: $gl-text-color;
+ color: var(--gl-text-color, $gl-text-color);
}
svg {
@@ -214,18 +249,18 @@
height: 25px;
position: absolute;
top: -31px;
- border-top: 2px solid $border-color;
+ border-top: 2px solid var(--border-color, $border-color);
}
&::after {
left: -44px;
- border-right: 2px solid $border-color;
+ border-right: 2px solid var(--border-color, $border-color);
border-radius: 0 20px;
}
&::before {
right: -44px;
- border-left: 2px solid $border-color;
+ border-left: 2px solid var(--border-color, $border-color);
border-radius: 20px 0 0;
}
}
@@ -281,7 +316,7 @@
a.build-content:hover,
button.build-content:hover {
- background-color: $gray-darker;
+ background-color: var(--gray-100, $gray-100);
border: 1px solid $dropdown-toggle-active-border-color;
}
@@ -292,7 +327,7 @@
position: absolute;
top: 48%;
right: -48px;
- border-top: 2px solid $border-color;
+ border-top: 2px solid var(--border-color, $border-color);
width: 48px;
height: 1px;
}
@@ -305,7 +340,7 @@
content: '';
top: -49px;
position: absolute;
- border-bottom: 2px solid $border-color;
+ border-bottom: 2px solid var(--border-color, $border-color);
width: 25px;
height: 69px;
}
@@ -313,14 +348,14 @@
// Right connecting curves
&::after {
right: -25px;
- border-right: 2px solid $border-color;
+ border-right: 2px solid var(--border-color, $border-color);
border-radius: 0 0 20px;
}
// Left connecting curves
&::before {
left: -25px;
- border-left: 2px solid $border-color;
+ border-left: 2px solid var(--border-color, $border-color);
border-radius: 0 0 0 20px;
}
}
@@ -355,7 +390,7 @@
line-height: 0;
svg {
- fill: $gl-text-color-secondary;
+ fill: var(--gray-500, $gray-500);
}
.spinner {
@@ -453,13 +488,13 @@
left: -6px;
margin-top: 3px;
border-width: 7px 5px 7px 0;
- border-right-color: $border-color;
+ border-right-color: var(--border-color, $border-color);
}
&::after {
left: -5px;
border-width: 10px 7px 10px 0;
- border-right-color: $white;
+ border-right-color: var(--white, $white);
}
}
@@ -484,5 +519,5 @@
}
.progress-bar.bg-primary {
- background-color: $blue-500 !important;
+ background-color: var(--blue-500, $blue-500) !important;
}
diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss
index e0e56893afc..dbde7933a8b 100644
--- a/app/assets/stylesheets/page_bundles/pipelines.scss
+++ b/app/assets/stylesheets/page_bundles/pipelines.scss
@@ -22,7 +22,7 @@
min-width: 170px; //Guarantees buttons don't break in several lines.
.btn-default {
- color: $gl-text-color-secondary;
+ color: var(--gray-500, $gray-500);
}
.btn.btn-retry:hover,
@@ -32,7 +32,7 @@
}
svg path {
- fill: $gl-text-color-secondary;
+ fill: var(--gray-500, $gray-500);
}
.dropdown-menu {
@@ -42,12 +42,7 @@
.dropdown-toggle,
.dropdown-menu {
- color: $gl-text-color-secondary;
-
- .fa {
- color: $gl-text-color-secondary;
- font-size: 14px;
- }
+ color: var(--gray-500, $gray-500);
}
.btn-group.open .btn-default {
diff --git a/app/assets/stylesheets/page_bundles/profile_two_factor_auth.scss b/app/assets/stylesheets/page_bundles/profile_two_factor_auth.scss
new file mode 100644
index 00000000000..3b4b1fdcded
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/profile_two_factor_auth.scss
@@ -0,0 +1,11 @@
+@media print {
+ .codes-to-print {
+ background-color: var(--white);
+ height: 100%;
+ width: 100%;
+ position: fixed;
+ top: 0;
+ left: 0;
+ margin: 0;
+ }
+}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 4e27f438e36..f7b8a4c5b84 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -58,22 +58,6 @@
}
}
-.cluster-application-banner {
- height: 45px;
- display: flex;
- align-items: center;
- justify-content: space-between;
-}
-
-.cluster-application-banner-close {
- align-self: flex-start;
- font-weight: 500;
- font-size: 20px;
- color: $orange-500;
- opacity: 1;
- margin: $gl-padding-8 14px 0 0;
-}
-
.cluster-application-description {
flex: 1;
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 17474b95e50..9b17da80023 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -174,12 +174,6 @@
}
.commit-actions {
- @include media-breakpoint-up(sm) {
- .fa-spinner {
- font-size: 12px;
- }
- }
-
.ci-status-icon svg {
vertical-align: text-bottom;
}
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index f357d508d5d..f237d57aa88 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -41,12 +41,6 @@
@include media-breakpoint-down(xs) {
width: 100%;
margin-top: 10px;
-
- > .issue-btn-group {
- > .btn {
- width: 100%;
- }
- }
}
}
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index 5c845c37e90..e0e10d63f8e 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -74,10 +74,6 @@
justify-content: flex-end;
}
- .select2 {
- float: right;
- }
-
.encoding-selector,
.soft-wrap-toggle {
display: inline-block;
@@ -220,10 +216,6 @@
}
}
-.editor-title-row {
- margin-bottom: 20px;
-}
-
.popover.suggest-gitlab-ci-yml {
z-index: $header-zindex - 1;
}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index e73b6b18afd..aeda91c1714 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -80,12 +80,6 @@
.btn-success {
width: 100%;
}
-
- .dropdown .dropdown-toggle .fa-chevron-down {
- position: absolute;
- top: 11px;
- right: 8px;
- }
}
}
@@ -299,12 +293,6 @@ table.pipeline-project-metrics tr td {
padding: $gl-padding;
}
-.mattermost-icon svg {
- width: 16px;
- height: 16px;
- vertical-align: text-bottom;
-}
-
.mattermost-team-name {
color: $gl-text-color-secondary;
}
diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss
deleted file mode 100644
index 74f80a11471..00000000000
--- a/app/assets/stylesheets/pages/import.scss
+++ /dev/null
@@ -1,61 +0,0 @@
-.import-jobs-to-col {
- width: 39%;
-}
-
-.import-jobs-status-col {
- width: 15%;
-}
-
-.import-jobs-cta-col {
- width: 1%;
-}
-
-.import-project-name-input {
- border-radius: 0 $border-radius-default $border-radius-default 0;
- position: relative;
- left: -1px;
- max-width: 300px;
-}
-
-.import-namespace-select {
- > .select2-choice {
- border-radius: $border-radius-default 0 0 $border-radius-default;
- position: relative;
- left: 1px;
- }
-}
-
-.import-slash-divider {
- background-color: $gray-lightest;
- border: 1px solid $border-color;
-}
-
-.import-row {
- height: 55px;
-}
-
-.import-table {
- .import-jobs-from-col,
- .import-jobs-to-col,
- .import-jobs-status-col,
- .import-jobs-cta-col {
- border-bottom-width: 1px;
- padding-left: $gl-padding;
- }
-}
-
-.import-projects-loading-icon {
- margin-top: $gl-padding-32;
-}
-
-.btn-import {
- .loading-icon {
- display: none;
- }
-
- &.is-loading {
- .loading-icon {
- display: inline-block;
- }
- }
-}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index cc4827f75d4..e5528c25e82 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -10,6 +10,7 @@
}
.limit-container-width {
+ .flash-container,
.detail-page-header,
.page-content-header,
.commit-box,
@@ -112,7 +113,7 @@
position: absolute;
bottom: 0;
right: 0;
- text-shadow: -1px -1px 2px $white, 1px -1px 2px $white, -1px 1px 2px $white, 1px 1px 2px $white;
+ filter: drop-shadow(0 0 0.5px $white) drop-shadow(0 0 1px $white) drop-shadow(0 0 2px $white);
}
}
@@ -199,10 +200,6 @@
border: 0;
}
- .select2-container span {
- margin-top: 0;
- }
-
&.assignee {
.author-link {
display: block;
@@ -395,6 +392,13 @@
text-align: center;
}
+ .merge-icon {
+ height: 12px;
+ width: 12px;
+ bottom: -5px;
+ right: 4px;
+ }
+
.sidebar-collapsed-icon {
display: flex;
flex-direction: column;
@@ -405,7 +409,7 @@
text-align: center;
color: $gl-text-color-secondary;
- svg {
+ > svg {
fill: $gl-text-color-secondary;
}
@@ -413,7 +417,7 @@
&:hover .todo-undone {
color: $gl-text-color;
- svg {
+ > svg {
fill: $gl-text-color;
}
}
@@ -485,10 +489,6 @@
display: none;
}
- .merge-icon {
- font-size: 10px;
- }
-
.multiple-users {
position: relative;
height: 24px;
@@ -697,10 +697,6 @@
.issuable-list {
li {
- .issue-box {
- display: flex;
- }
-
.issuable-info-container {
flex: 1;
display: flex;
@@ -894,29 +890,6 @@
}
}
-.issuable-close-button,
-.issuable-close-toggle {
- @include transition(border-color, color);
-}
-
-.issuable-close-dropdown {
- .dropdown-menu {
- min-width: 270px;
- left: auto;
- right: 0;
- }
-
- .description {
- .text {
- margin: 0;
- }
- }
-
- .dropdown-toggle > .icon {
- margin: 0 3px;
- }
-}
-
/*
* Following overrides are done to prevent
* legacy dropdown styles from influencing
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 08faebc8ec0..1caf62067a6 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -92,6 +92,11 @@ ul.related-merge-requests > li {
}
}
+.issues-footer {
+ padding-top: $gl-padding;
+ padding-bottom: 37px;
+}
+
.issues-nav-controls,
.new-branch-col {
font-size: 0;
@@ -196,14 +201,6 @@ ul.related-merge-requests > li {
}
}
}
-
- .create-merge-request-dropdown-toggle {
- .fa-caret-down {
- pointer-events: none;
- color: inherit;
- margin-left: 0;
- }
- }
}
.discussion-reply-holder {
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index a8b489f1273..0ccde57746a 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -30,20 +30,6 @@
margin-bottom: 0;
}
- .member-controls {
- .fa {
- line-height: inherit;
- }
- }
-
- .btn-remove {
- width: 100%;
-
- @include media-breakpoint-up(sm) {
- width: auto;
- }
- }
-
&.existing-title {
@include media-breakpoint-up(sm) {
float: left;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index a0ac55e4c6c..efca82def92 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -53,6 +53,7 @@ $mr-widget-min-height: 69px;
position: relative;
border: 1px solid $border-color;
border-radius: $border-radius-default;
+ background: var(--white, $white);
.gl-skeleton-loader {
display: block;
@@ -61,7 +62,7 @@ $mr-widget-min-height: 69px;
.mr-widget-extension {
border-top: 1px solid $border-color;
- background-color: $gray-light;
+ background-color: $gray-50;
&.clickable:hover {
background-color: $gray-100;
@@ -87,6 +88,7 @@ $mr-widget-min-height: 69px;
border: 1px solid $border-color;
border-radius: $border-radius-default;
border-top: 0;
+ background: var(--white, $white);
}
.mr-widget-body,
@@ -161,12 +163,6 @@ $mr-widget-min-height: 69px;
.btn {
font-size: $gl-font-size;
-
- &.dropdown-toggle {
- .fa {
- color: inherit;
- }
- }
}
.accept-merge-holder {
@@ -287,10 +283,6 @@ $mr-widget-min-height: 69px;
margin-top: 0;
margin-bottom: 0;
- &.has-conflicts .fa-exclamation-triangle {
- color: $orange-500;
- }
-
time {
font-weight: $gl-font-weight-normal;
}
@@ -343,13 +335,6 @@ $mr-widget-min-height: 69px;
}
}
- .dropdown-toggle {
- .fa {
- margin-left: 0;
- color: inherit;
- }
- }
-
.has-custom-error {
display: inline-block;
}
@@ -507,19 +492,6 @@ $mr-widget-min-height: 69px;
display: none;
}
-#modal_merge_info .modal-dialog {
- .dark {
- margin-right: 40px;
- }
-
- .btn-clipboard {
- margin-right: 20px;
- margin-top: 5px;
- position: absolute;
- right: 0;
- }
-}
-
.mr-links {
padding-left: $gl-padding-8 + $status-icon-size + $gl-btn-padding;
@@ -560,16 +532,13 @@ $mr-widget-min-height: 69px;
border-radius: $border-radius-default;
padding: $gl-padding;
border: 1px solid $border-color;
+ background: var(--white, $white);
min-height: $mr-widget-min-height;
@include media-breakpoint-up(md) {
align-items: center;
}
- .dropdown-toggle .fa {
- color: $gl-text-color;
- }
-
.git-merge-container {
justify-content: space-between;
flex: 1;
@@ -720,7 +689,7 @@ $mr-widget-min-height: 69px;
z-index: 199;
white-space: nowrap;
- .dropdown-menu-toggle {
+ .gl-dropdown-toggle {
width: auto;
max-width: 170px;
@@ -778,7 +747,7 @@ $mr-widget-min-height: 69px;
.epic-tabs-holder {
top: $header-height;
z-index: 250;
- background-color: $white;
+ background-color: $body-bg;
border-bottom: 1px solid $border-color;
.with-system-header & {
@@ -1039,3 +1008,11 @@ $mr-widget-min-height: 69px;
.diff-file-row.is-active {
background-color: $gray-50;
}
+
+.mr-conflict-loader {
+ max-width: 334px;
+
+ > svg {
+ vertical-align: middle;
+ }
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index e23ec25a2f3..4216091e8a9 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -190,8 +190,7 @@ $note-form-margin-left: 72px;
border: 1px solid darken($gray-100, 25%);
}
- .note-headline-light,
- .fa-spinner {
+ .note-headline-light {
margin-left: 3px;
}
}
@@ -249,16 +248,6 @@ $note-form-margin-left: 72px;
.note-emoji-button {
position: relative;
line-height: 1;
-
- .fa-spinner {
- display: none;
- }
-
- &.is-loading {
- .fa-spinner {
- display: inline-block;
- }
- }
}
}
@@ -361,7 +350,7 @@ $note-form-margin-left: 72px;
left: $gl-padding-24;
right: 0;
bottom: 0;
- background: linear-gradient(rgba($white, 0.1) -100px, $white 100%);
+ background: linear-gradient(rgba($white, 0.1) -100px, $body-bg 100%);
}
}
}
@@ -407,8 +396,6 @@ $note-form-margin-left: 72px;
.discussion-body .diff-file {
.file-title {
cursor: default;
- line-height: 42px;
- padding: 0 $gl-padding;
border-top: 1px solid $border-color;
border-radius: 0;
@@ -791,13 +778,6 @@ $note-form-margin-left: 72px;
outline: none;
color: $blue-600;
}
-
- .fa {
- margin-right: 3px;
- font-size: 10px;
- line-height: 18px;
- vertical-align: top;
- }
}
.note-role {
diff --git a/app/assets/stylesheets/pages/notifications.scss b/app/assets/stylesheets/pages/notifications.scss
index e1cbf0e6654..33ab42b5511 100644
--- a/app/assets/stylesheets/pages/notifications.scss
+++ b/app/assets/stylesheets/pages/notifications.scss
@@ -1,6 +1,4 @@
.notification-list-item {
- line-height: 34px;
-
.dropdown-menu {
@extend .dropdown-menu-right;
}
@@ -37,8 +35,4 @@
.notification {
position: relative;
top: 1px;
-
- .fa {
- font-size: 18px;
- }
}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index b37aa6cd285..89be1c024db 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -46,11 +46,6 @@
fill: $gl-text-color;
}
- .fa {
- font-size: 12px;
- color: $gl-text-color;
- }
-
.commit-sha {
color: $blue-600;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 09501d3713d..7fafd28be56 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -10,12 +10,6 @@
}
.input-group {
- .select2-container {
- display: unset;
- max-width: unset;
- flex-grow: 1;
- }
-
> div {
&:last-child {
padding-right: 0;
@@ -52,7 +46,6 @@
flex-grow: 1;
}
- + .select2 a,
+ .btn-default {
border-radius: 0 $border-radius-base $border-radius-base 0;
}
@@ -147,23 +140,10 @@
margin-left: 0;
}
- .fa {
- color: $layout-link-gray;
- }
-
svg {
fill: $layout-link-gray;
}
- .fa-caret-down {
- margin-left: 3px;
- line-height: 0;
-
- &.dropdown-btn-icon {
- margin-left: 0;
- }
- }
-
.notifications-icon {
top: 1px;
margin-right: 0;
@@ -179,13 +159,6 @@
height: 24px;
}
- .dropdown-toggle,
- .clone-dropdown-btn {
- .fa {
- color: unset;
- }
- }
-
.home-panel-action-button,
.project-action-button {
margin: $gl-padding $gl-padding-8 0 0;
@@ -258,10 +231,6 @@
color: $gray-700;
}
-.transfer-project .select2-container {
- min-width: 200px;
-}
-
.deploy-key {
// Ensure that the fingerprint does not overflow on small screens
.fingerprint {
@@ -512,7 +481,7 @@
top: 0;
height: calc(100% - #{$browser-scrollbar-size});
- .fa {
+ svg {
top: 50%;
margin-top: -$gl-padding-8;
}
@@ -1057,11 +1026,6 @@ pre.light-well {
margin-bottom: 0;
}
}
-
- .select2-choice {
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- }
}
.project-home-empty {
diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss
index 8ed6936475b..856e49bd144 100644
--- a/app/assets/stylesheets/pages/runners.scss
+++ b/app/assets/stylesheets/pages/runners.scss
@@ -12,16 +12,18 @@
}
}
-.runner-status-online {
- color: $green-600;
-}
+.runner-status {
+ &.runner-status-online {
+ background-color: $green-600;
+ }
-.runner-status-offline {
- color: $gray-darkest;
-}
+ &.runner-status-offline {
+ background-color: $gray-darkest;
+ }
-.runner-status-paused {
- color: $red-500;
+ &.runner-status-paused {
+ background-color: $red-500;
+ }
}
.runner {
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 502a1881fd2..cd99c667001 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -1,5 +1,7 @@
$search-dropdown-max-height: 400px;
$search-avatar-size: 16px;
+$search-sidebar-min-width: 240px;
+$search-sidebar-max-width: 300px;
.search-results {
.search-result-row {
@@ -17,6 +19,13 @@ $search-avatar-size: 16px;
}
}
+.search-sidebar {
+ @include media-breakpoint-up(md) {
+ min-width: $search-sidebar-min-width;
+ max-width: $search-sidebar-max-width;
+ }
+}
+
.search form:hover,
.file-finder-input:hover,
.issuable-search-form:hover,
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 7b18e3774d8..335e177d169 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -169,11 +169,6 @@
.form-check {
margin-bottom: 10px;
- i.fa {
- margin: 2px 0;
- font-size: 20px;
- }
-
.option-title {
font-weight: $gl-font-weight-normal;
display: inline-block;
@@ -193,7 +188,7 @@
}
&.disabled {
- i.fa {
+ svg {
opacity: 0.5;
}
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index 429181c2ad4..8f3574a337b 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -1,8 +1,11 @@
+.project-last-commit {
+ min-height: 4.75rem;
+}
+
.tree-holder {
.nav-block {
margin: 16px 0;
- .btn .fa,
.btn svg {
color: $gl-text-color-secondary;
}
@@ -69,7 +72,7 @@
}
.btn {
- margin: 10px 0 0;
+ margin-top: 10px;
}
}
}
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index b3e53e35f6e..af43c532b7c 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -977,12 +977,12 @@ body.navless {
border-color: #e3e3e3;
color: #303030;
}
-.btn.btn-success, .btn.btn-register {
+.btn.btn-success {
background-color: #108548;
border-color: #217645;
color: #fff;
}
-.btn.btn-success:active, .btn.btn-success.active, .btn.btn-register:active, .btn.btn-register.active {
+.btn.btn-success:active, .btn.btn-success.active {
box-shadow: rgba(0, 0, 0, 0.16);
background-color: #24663b;
border-color: #0d532a;
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 6ab02bd5e27..7f2bea9bf26 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -1,63 +1,63 @@
$gray-10: #1f1f1f;
-$gray-50: #2e2e2e;
-$gray-100: #4f4f4f;
-$gray-200: #707070;
-$gray-300: #919191;
-$gray-400: #a7a7a7;
-$gray-500: #bababa;
-$gray-600: #ccc;
-$gray-700: #dfdfdf;
-$gray-800: #f2f2f2;
+$gray-50: #303030;
+$gray-100: #404040;
+$gray-200: #525252;
+$gray-300: #5e5e5e;
+$gray-400: #868686;
+$gray-500: #999;
+$gray-600: #bfbfbf;
+$gray-700: #dbdbdb;
+$gray-800: #f0f0f0;
$gray-900: #fafafa;
$gray-950: #fff;
-$green-50: #072b15;
-$green-100: #0a4020;
-$green-200: #0e5a2d;
-$green-300: #12753a;
-$green-400: #168f48;
-$green-500: #1aaa55;
-$green-600: #37b96d;
-$green-700: #75d09b;
-$green-800: #b3e6c8;
-$green-900: #dcf5e7;
+$green-50: #0a4020;
+$green-100: #0d532a;
+$green-200: #24663b;
+$green-300: #217645;
+$green-400: #108548;
+$green-500: #2da160;
+$green-600: #52b87a;
+$green-700: #91d4a8;
+$green-800: #c3e6cd;
+$green-900: #ecf4ee;
$green-950: #f1fdf6;
-$blue-50: #0a2744;
-$blue-100: #0f3b66;
-$blue-200: #134a81;
-$blue-300: #17599c;
-$blue-400: #1b69b6;
-$blue-500: #1f78d1;
-$blue-600: #418cd8;
-$blue-700: #73afea;
-$blue-800: #b8d6f4;
-$blue-900: #e4f0fb;
-$blue-950: #f6fafe;
-
-$orange-50: #592800;
-$orange-100: #853c00;
-$orange-200: #a35200;
-$orange-300: #c26700;
-$orange-400: #de7e00;
-$orange-500: #fc9403;
-$orange-600: #fca429;
-$orange-700: #fdbc60;
-$orange-800: #fed69f;
-$orange-900: #fff1de;
-$orange-950: #fffaf4;
-
-$red-50: #4b140b;
-$red-100: #711e11;
-$red-200: #8b2615;
-$red-300: #a62d19;
-$red-400: #c0341d;
-$red-500: #db3b21;
-$red-600: #e05842;
-$red-700: #ea8271;
-$red-800: #f2b4a9;
-$red-900: #fbe5e1;
-$red-950: #fef6f5;
+$blue-50: #033464;
+$blue-100: #064787;
+$blue-200: #0b5cad;
+$blue-300: #1068bf;
+$blue-400: #1f75cb;
+$blue-500: #428fdc;
+$blue-600: #63a6e9;
+$blue-700: #9dc7f1;
+$blue-800: #cbe2f9;
+$blue-900: #e9f3fc;
+$blue-950: #f2f9ff;
+
+$orange-50: #5c2900;
+$orange-100: #703800;
+$orange-200: #8f4700;
+$orange-300: #9e5400;
+$orange-400: #ab6100;
+$orange-500: #c17d10;
+$orange-600: #d99530;
+$orange-700: #e9be74;
+$orange-800: #f5d9a8;
+$orange-900: #fdf1dd;
+$orange-950: #fff4e1;
+
+$red-50: #660e00;
+$red-100: #8d1300;
+$red-200: #ae1800;
+$red-300: #c91c00;
+$red-400: #dd2b0e;
+$red-500: #ec5941;
+$red-600: #f57f6c;
+$red-700: #fcb5aa;
+$red-800: #fdd4cd;
+$red-900: #fcf1ef;
+$red-950: #fff4f3;
$indigo-50: #1a1a40;
$indigo-100: #292961;
@@ -166,14 +166,16 @@ body.gl-dark {
--white: #{$white};
--black: #{$black};
+
+ --svg-status-bg: #{$white};
}
$border-white-light: $gray-900;
$border-white-normal: $gray-900;
-$body-bg: $gray-50;
-$input-bg: $gray-100;
-$input-focus-bg: $gray-100;
+$body-bg: $gray-10;
+$input-bg: $white;
+$input-focus-bg: $white;
$input-color: $gray-900;
$input-group-addon-bg: $gray-900;
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index 85115cfd5d9..417377b514e 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -64,14 +64,20 @@
color: $search-and-nav-links;
> a {
+ .notification-dot {
+ border: 2px solid $nav-svg-color;
+ }
+
+ &.header-help-dropdown-toggle {
+ .notification-dot {
+ background-color: $search-and-nav-links;
+ }
+ }
+
&.header-user-dropdown-toggle {
.header-user-avatar {
border-color: $search-and-nav-links;
}
-
- .header-user-notification-dot {
- border: 2px solid $nav-svg-color;
- }
}
&:hover,
@@ -84,9 +90,14 @@
fill: currentColor;
}
- &.header-user-dropdown-toggle .header-user-notification-dot {
+ .notification-dot {
+ will-change: border-color, background-color;
border-color: $nav-svg-color + 33;
}
+
+ &.header-help-dropdown-toggle .notification-dot {
+ background-color: $white;
+ }
}
}
@@ -101,9 +112,15 @@
}
}
- &.header-user-dropdown-toggle .header-user-notification-dot {
+ .notification-dot {
border-color: $white;
}
+
+ &.header-help-dropdown-toggle {
+ .notification-dot {
+ background-color: $nav-svg-color;
+ }
+ }
}
.impersonated-user,
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index a3bb7c868df..bf251993c38 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -129,3 +129,30 @@
content: '';
display: flex;
}
+
+// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1085
+.gl-md-flex-direction-column {
+ @media (min-width: $breakpoint-md) {
+ flex-direction: column;
+ }
+}
+
+// Same as above
+.gl-md-flex-direction-column\! {
+ @media (min-width: $breakpoint-md) {
+ flex-direction: column !important;
+ }
+}
+
+// These will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1091
+.gl-w-10p {
+ width: 10%;
+}
+
+.gl-w-20p {
+ width: 20%;
+}
+
+.gl-w-40p {
+ width: 40%;
+}
diff --git a/app/controllers/admin/cohorts_controller.rb b/app/controllers/admin/cohorts_controller.rb
index d5cd9c55422..a26dc554506 100644
--- a/app/controllers/admin/cohorts_controller.rb
+++ b/app/controllers/admin/cohorts_controller.rb
@@ -5,7 +5,7 @@ class Admin::CohortsController < Admin::ApplicationController
track_unique_visits :index, target_id: 'i_analytics_cohorts'
- feature_category :instance_statistics
+ feature_category :devops_reports
def index
if Gitlab::CurrentSettings.usage_ping_enabled
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index 33a8cc4ae42..da89276f5eb 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -2,7 +2,6 @@
class Admin::DashboardController < Admin::ApplicationController
include CountHelper
- helper_method :show_license_breakdown?
COUNTED_ITEMS = [Project, User, Group].freeze
@@ -23,10 +22,6 @@ class Admin::DashboardController < Admin::ApplicationController
def stats
@users_statistics = UsersStatistics.latest
end
-
- def show_license_breakdown?
- false
- end
end
Admin::DashboardController.prepend_if_ee('EE::Admin::DashboardController')
diff --git a/app/controllers/admin/instance_review_controller.rb b/app/controllers/admin/instance_review_controller.rb
index db304c82dd6..88ca2c88aab 100644
--- a/app/controllers/admin/instance_review_controller.rb
+++ b/app/controllers/admin/instance_review_controller.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
class Admin::InstanceReviewController < Admin::ApplicationController
- feature_category :instance_statistics
+ feature_category :devops_reports
def index
redirect_to("#{::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL}/instance_review?#{instance_review_params}")
diff --git a/app/controllers/admin/instance_statistics_controller.rb b/app/controllers/admin/instance_statistics_controller.rb
index 05a0a1ce314..30891fcfe7c 100644
--- a/app/controllers/admin/instance_statistics_controller.rb
+++ b/app/controllers/admin/instance_statistics_controller.rb
@@ -7,7 +7,7 @@ class Admin::InstanceStatisticsController < Admin::ApplicationController
track_unique_visits :index, target_id: 'i_analytics_instance_statistics'
- feature_category :instance_statistics
+ feature_category :devops_reports
def index
end
diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb
index aab8705f5cb..4247446365c 100644
--- a/app/controllers/admin/integrations_controller.rb
+++ b/app/controllers/admin/integrations_controller.rb
@@ -4,6 +4,8 @@ class Admin::IntegrationsController < Admin::ApplicationController
include IntegrationsActions
include ServicesHelper
+ before_action :not_found, unless: -> { instance_level_integrations? }
+
feature_category :integrations
private
@@ -12,10 +14,6 @@ class Admin::IntegrationsController < Admin::ApplicationController
Service.find_or_initialize_non_project_specific_integration(name, instance: true)
end
- def integrations_enabled?
- instance_level_integrations?
- end
-
def scoped_edit_integration_path(integration)
edit_admin_application_settings_integration_path(integration)
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 2d0bb0bfebc..3fe972d1917 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -72,6 +72,16 @@ class Admin::UsersController < Admin::ApplicationController
end
end
+ def reject
+ result = Users::RejectService.new(current_user).execute(user)
+
+ if result[:status] == :success
+ redirect_to admin_users_path, status: :found, notice: _("You've rejected %{user}" % { user: user.name })
+ else
+ redirect_back_or_admin_user(alert: result[:message])
+ end
+ end
+
def activate
return redirect_back_or_admin_user(notice: _("Error occurred. A blocked user must be unblocked to be activated")) if user.blocked?
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c38c6abddc1..b78029a52cd 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -61,8 +61,7 @@ class ApplicationController < ActionController::Base
:gitea_import_enabled?, :github_import_configured?,
:gitlab_import_enabled?, :gitlab_import_configured?,
:bitbucket_import_enabled?, :bitbucket_import_configured?,
- :bitbucket_server_import_enabled?,
- :google_code_import_enabled?, :fogbugz_import_enabled?,
+ :bitbucket_server_import_enabled?, :fogbugz_import_enabled?,
:git_import_enabled?, :gitlab_project_import_enabled?,
:manifest_import_enabled?, :phabricator_import_enabled?
@@ -434,10 +433,6 @@ class ApplicationController < ActionController::Base
Gitlab::Auth::OAuth::Provider.enabled?(:bitbucket)
end
- def google_code_import_enabled?
- Gitlab::CurrentSettings.import_sources.include?('google_code')
- end
-
def fogbugz_import_enabled?
Gitlab::CurrentSettings.import_sources.include?('fogbugz')
end
diff --git a/app/controllers/boards/lists_controller.rb b/app/controllers/boards/lists_controller.rb
index aecd287370f..19a4508c061 100644
--- a/app/controllers/boards/lists_controller.rb
+++ b/app/controllers/boards/lists_controller.rb
@@ -19,12 +19,12 @@ module Boards
end
def create
- list = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board)
+ response = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board)
- if list.valid?
- render json: serialize_as_json(list)
+ if response.success?
+ render json: serialize_as_json(response.payload[:list])
else
- render json: list.errors, status: :unprocessable_entity
+ render json: { errors: response.errors }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/concerns/dependency_proxy/auth.rb b/app/controllers/concerns/dependency_proxy/auth.rb
new file mode 100644
index 00000000000..1276feedba6
--- /dev/null
+++ b/app/controllers/concerns/dependency_proxy/auth.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ module Auth
+ extend ActiveSupport::Concern
+
+ included do
+ # We disable `authenticate_user!` since the `DependencyProxy::Auth` performs auth using JWT token
+ skip_before_action :authenticate_user!, raise: false
+ prepend_before_action :authenticate_user_from_jwt_token!
+ end
+
+ def authenticate_user_from_jwt_token!
+ return unless dependency_proxy_for_private_groups?
+
+ authenticate_with_http_token do |token, _|
+ user = user_from_token(token)
+ sign_in(user) if user
+ end
+
+ request_bearer_token! unless current_user
+ end
+
+ private
+
+ def dependency_proxy_for_private_groups?
+ Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true)
+ end
+
+ def request_bearer_token!
+ # unfortunately, we cannot use https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token.html#method-i-authentication_request
+ response.headers['WWW-Authenticate'] = ::DependencyProxy::Registry.authenticate_header
+ render plain: '', status: :unauthorized
+ end
+
+ def user_from_token(token)
+ token_payload = DependencyProxy::AuthTokenService.decoded_token_payload(token)
+ User.find(token_payload['user_id'])
+ rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature
+ nil
+ end
+ end
+end
diff --git a/app/controllers/concerns/dependency_proxy/group_access.rb b/app/controllers/concerns/dependency_proxy/group_access.rb
new file mode 100644
index 00000000000..2a923d02752
--- /dev/null
+++ b/app/controllers/concerns/dependency_proxy/group_access.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ module GroupAccess
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :verify_dependency_proxy_enabled!
+ before_action :authorize_read_dependency_proxy!
+ end
+
+ private
+
+ def verify_dependency_proxy_enabled!
+ render_404 unless group.dependency_proxy_feature_available?
+ end
+
+ def authorize_read_dependency_proxy!
+ access_denied! unless can?(current_user, :read_dependency_proxy, group)
+ end
+
+ def authorize_admin_dependency_proxy!
+ access_denied! unless can?(current_user, :admin_dependency_proxy, group)
+ end
+ end
+end
diff --git a/app/controllers/concerns/dependency_proxy_access.rb b/app/controllers/concerns/dependency_proxy_access.rb
deleted file mode 100644
index 5036d0cfce4..00000000000
--- a/app/controllers/concerns/dependency_proxy_access.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-module DependencyProxyAccess
- extend ActiveSupport::Concern
-
- included do
- before_action :verify_dependency_proxy_enabled!
- before_action :authorize_read_dependency_proxy!
- end
-
- private
-
- def verify_dependency_proxy_enabled!
- render_404 unless group.dependency_proxy_feature_available?
- end
-
- def authorize_read_dependency_proxy!
- access_denied! unless can?(current_user, :read_dependency_proxy, group)
- end
-
- def authorize_admin_dependency_proxy!
- access_denied! unless can?(current_user, :admin_dependency_proxy, group)
- end
-end
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index 8e9b038437d..baebedb8e5d 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -6,7 +6,6 @@ module IntegrationsActions
included do
include ServiceParams
- before_action :not_found, unless: :integrations_enabled?
before_action :integration, only: [:edit, :update, :test]
end
@@ -43,12 +42,16 @@ module IntegrationsActions
render json: {}, status: :ok
end
- private
+ def reset
+ integration.destroy!
+
+ flash[:notice] = s_('Integrations|This integration, and inheriting projects were reset.')
- def integrations_enabled?
- false
+ render json: {}, status: :ok
end
+ private
+
def integration
# Using instance variable `@service` still required as it's used in ServiceParams.
# Should be removed once that is refactored to use `@integration`.
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 0d7af57328a..3f5f3b6e9df 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -150,7 +150,7 @@ module IssuableCollections
common_attributes + [:project, project: :namespace]
when 'MergeRequest'
common_attributes + [
- :target_project, :latest_merge_request_diff, :approvals, :approved_by_users,
+ :target_project, :latest_merge_request_diff, :approvals, :approved_by_users, :reviewers,
source_project: :route, head_pipeline: :project, target_project: :namespace
]
end
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index a19c43a227a..c295290a123 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -23,6 +23,9 @@ module ServiceParams
:comment_detail,
:confidential_issues_events,
:confluence_url,
+ :datadog_site,
+ :datadog_env,
+ :datadog_service,
:default_irc_uri,
:device,
:disable_diffs,
diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb
index 0153ede2821..c93e75b438b 100644
--- a/app/controllers/concerns/snippets_actions.rb
+++ b/app/controllers/concerns/snippets_actions.rb
@@ -9,11 +9,14 @@ module SnippetsActions
include Gitlab::NoteableMetadata
include Snippets::SendBlob
include SnippetsSort
+ include RedisTracking
included do
skip_before_action :verify_authenticity_token,
if: -> { action_name == 'show' && js_request? }
+ track_redis_hll_event :show, name: 'i_snippets_show', feature: :usage_data_i_snippets_show, feature_default_enabled: true
+
respond_to :html
end
diff --git a/app/controllers/concerns/sorting_preference.rb b/app/controllers/concerns/sorting_preference.rb
index a51b68147d5..8d8845e2f41 100644
--- a/app/controllers/concerns/sorting_preference.rb
+++ b/app/controllers/concerns/sorting_preference.rb
@@ -4,8 +4,11 @@ module SortingPreference
include SortingHelper
include CookiesHelper
- def set_sort_order
- set_sort_order_from_user_preference || set_sort_order_from_cookie || params[:sort] || default_sort_order
+ def set_sort_order(field = sorting_field, default_order = default_sort_order)
+ set_sort_order_from_user_preference(field) ||
+ set_sort_order_from_cookie(field) ||
+ params[:sort] ||
+ default_order
end
# Implement sorting_field method on controllers
@@ -29,42 +32,42 @@ module SortingPreference
private
- def set_sort_order_from_user_preference
+ def set_sort_order_from_user_preference(field = sorting_field)
return unless current_user
- return unless sorting_field
+ return unless field
user_preference = current_user.user_preference
sort_param = params[:sort]
- sort_param ||= user_preference[sorting_field]
+ sort_param ||= user_preference[field]
return sort_param if Gitlab::Database.read_only?
- if user_preference[sorting_field] != sort_param
- user_preference.update(sorting_field => sort_param)
+ if user_preference[field] != sort_param
+ user_preference.update(field => sort_param)
end
sort_param
end
- def set_sort_order_from_cookie
+ def set_sort_order_from_cookie(field = sorting_field)
return unless legacy_sort_cookie_name
sort_param = params[:sort] if params[:sort].present?
# fallback to legacy cookie value for backward compatibility
sort_param ||= cookies[legacy_sort_cookie_name]
- sort_param ||= cookies[remember_sorting_key]
+ sort_param ||= cookies[remember_sorting_key(field)]
sort_value = update_cookie_value(sort_param)
- set_secure_cookie(remember_sorting_key, sort_value)
+ set_secure_cookie(remember_sorting_key(field), sort_value)
sort_value
end
# Convert sorting_field to legacy cookie name for backwards compatibility
# :merge_requests_sort => 'mergerequest_sort'
# :issues_sort => 'issue_sort'
- def remember_sorting_key
- @remember_sorting_key ||= sorting_field
+ def remember_sorting_key(field = sorting_field)
+ @remember_sorting_key ||= field
.to_s
.split('_')[0..-2]
.map(&:singularize)
diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb
index 6abb2e16226..1ae90edd8f7 100644
--- a/app/controllers/concerns/wiki_actions.rb
+++ b/app/controllers/concerns/wiki_actions.rb
@@ -8,6 +8,8 @@ module WikiActions
include RedisTracking
extend ActiveSupport::Concern
+ RESCUE_GIT_TIMEOUTS_IN = %w[show edit history diff pages].freeze
+
included do
before_action { respond_to :html }
@@ -38,6 +40,12 @@ module WikiActions
feature: :track_unique_wiki_page_views, feature_default_enabled: true
helper_method :view_file_button, :diff_file_html_data
+
+ rescue_from ::Gitlab::Git::CommandTimedOut do |exc|
+ raise exc unless RESCUE_GIT_TIMEOUTS_IN.include?(action_name)
+
+ render 'shared/wikis/git_error'
+ end
end
def new
@@ -46,11 +54,7 @@ module WikiActions
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def pages
- @wiki_pages = Kaminari.paginate_array(
- wiki.list_pages(sort: params[:sort], direction: params[:direction])
- ).page(params[:page])
-
- @wiki_entries = WikiDirectory.group_pages(@wiki_pages)
+ @wiki_entries = WikiDirectory.group_pages(wiki_pages)
render 'shared/wikis/pages'
end
@@ -182,6 +186,10 @@ module WikiActions
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
+ def git_access
+ render 'shared/wikis/git_access'
+ end
+
private
def container
@@ -225,9 +233,19 @@ module WikiActions
unless @sidebar_page # Fallback to default sidebar
@sidebar_wiki_entries, @sidebar_limited = wiki.sidebar_entries
end
+ rescue ::Gitlab::Git::CommandTimedOut => e
+ @sidebar_error = e
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
+ def wiki_pages
+ strong_memoize(:wiki_pages) do
+ Kaminari.paginate_array(
+ wiki.list_pages(sort: params[:sort], direction: params[:direction])
+ ).page(params[:page])
+ end
+ end
+
def wiki_params
params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha)
end
diff --git a/app/controllers/concerns/workhorse_import_export_upload.rb b/app/controllers/concerns/workhorse_authorization.rb
index 3c52f4d7adf..a290ba256b6 100644
--- a/app/controllers/concerns/workhorse_import_export_upload.rb
+++ b/app/controllers/concerns/workhorse_authorization.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module WorkhorseImportExportUpload
+module WorkhorseAuthorization
extend ActiveSupport::Concern
include WorkhorseRequest
@@ -12,10 +12,9 @@ module WorkhorseImportExportUpload
def authorize
set_workhorse_internal_api_content_type
- authorized = ImportExportUploader.workhorse_authorize(
+ authorized = uploader_class.workhorse_authorize(
has_length: false,
- maximum_size: Gitlab::CurrentSettings.max_import_size.megabytes
- )
+ maximum_size: maximum_size.to_i)
render json: authorized
rescue SocketError
@@ -27,7 +26,18 @@ module WorkhorseImportExportUpload
def file_is_valid?(file)
return false unless file.is_a?(::UploadedFile)
+ file_extension_whitelist.include?(File.extname(file.original_filename).downcase.delete('.'))
+ end
+
+ def uploader_class
+ raise NotImplementedError
+ end
+
+ def maximum_size
+ raise NotImplementedError
+ end
+
+ def file_extension_whitelist
ImportExportUploader::EXTENSION_WHITELIST
- .include?(File.extname(file.original_filename).delete('.'))
end
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index f7a74f40e4b..aa3592ff209 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -108,7 +108,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
def default_sort_order
- sort_value_latest_activity
+ sort_value_name
end
def sorting_field
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 7a485eebfe3..d210d0f66fd 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -94,7 +94,7 @@ class Explore::ProjectsController < Explore::ApplicationController
end
def default_sort_order
- sort_value_latest_activity
+ sort_value_name
end
def sorting_field
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index b5deed70380..1852405e7cf 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -37,7 +37,11 @@ class GraphqlController < ApplicationController
rescue_from StandardError do |exception|
log_exception(exception)
- render_error("Internal server error")
+ if Rails.env.test? || Rails.env.development?
+ render_error("Internal server error: #{exception.message}")
+ else
+ render_error("Internal server error")
+ end
end
rescue_from Gitlab::Graphql::Variables::Invalid do |exception|
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index 9c2e361e92f..a504d2ce991 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -3,11 +3,14 @@
class Groups::ApplicationController < ApplicationController
include RoutableActions
include ControllerWithCrossProjectAccessCheck
+ include SortingHelper
+ include SortingPreference
layout 'group'
skip_before_action :authenticate_user!
before_action :group
+ before_action :set_sorting
requires_cross_project_access
private
@@ -57,6 +60,16 @@ class Groups::ApplicationController < ApplicationController
url_for(safe_params)
end
+
+ def set_sorting
+ if has_project_list?
+ @group_projects_sort = set_sort_order(Project::SORTING_PREFERENCE_FIELD, sort_value_name)
+ end
+ end
+
+ def has_project_list?
+ false
+ end
end
Groups::ApplicationController.prepend_if_ee('EE::Groups::ApplicationController')
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index c2d72610c66..093cdf258b2 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -8,7 +8,6 @@ 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_with_swimlanes, group, default_enabled: true)
end
feature_category :boards
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
index 718914dea35..10a6ad06ae5 100644
--- a/app/controllers/groups/children_controller.rb
+++ b/app/controllers/groups/children_controller.rb
@@ -2,12 +2,15 @@
module Groups
class ChildrenController < Groups::ApplicationController
+ extend ::Gitlab::Utils::Override
+
before_action :group
skip_cross_project_access_check :index
feature_category :subgroups
def index
+ params[:sort] ||= @group_projects_sort
parent = if params[:parent_id].present?
GroupFinder.new(current_user).execute(id: params[:parent_id])
else
@@ -40,5 +43,12 @@ module Groups
params: params.to_unsafe_h).execute
@children = @children.page(params[:page])
end
+
+ private
+
+ override :has_project_list?
+ def has_project_list?
+ true
+ end
end
end
diff --git a/app/controllers/groups/dependency_proxies_controller.rb b/app/controllers/groups/dependency_proxies_controller.rb
index 367dbafdd59..b896b240daf 100644
--- a/app/controllers/groups/dependency_proxies_controller.rb
+++ b/app/controllers/groups/dependency_proxies_controller.rb
@@ -2,7 +2,7 @@
module Groups
class DependencyProxiesController < Groups::ApplicationController
- include DependencyProxyAccess
+ include DependencyProxy::GroupAccess
before_action :authorize_admin_dependency_proxy!, only: :update
before_action :dependency_proxy
diff --git a/app/controllers/groups/dependency_proxy_auth_controller.rb b/app/controllers/groups/dependency_proxy_auth_controller.rb
new file mode 100644
index 00000000000..e3e9bd88e24
--- /dev/null
+++ b/app/controllers/groups/dependency_proxy_auth_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Groups::DependencyProxyAuthController < ApplicationController
+ include DependencyProxy::Auth
+
+ feature_category :dependency_proxy
+
+ def authenticate
+ render plain: '', status: :ok
+ end
+end
diff --git a/app/controllers/groups/dependency_proxy_for_containers_controller.rb b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
index f46902ef90f..0f640397320 100644
--- a/app/controllers/groups/dependency_proxy_for_containers_controller.rb
+++ b/app/controllers/groups/dependency_proxy_for_containers_controller.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
class Groups::DependencyProxyForContainersController < Groups::ApplicationController
- include DependencyProxyAccess
+ include DependencyProxy::Auth
+ include DependencyProxy::GroupAccess
include SendFileUpload
before_action :ensure_token_granted!
@@ -9,13 +10,13 @@ class Groups::DependencyProxyForContainersController < Groups::ApplicationContro
attr_reader :token
- feature_category :package_registry
+ feature_category :dependency_proxy
def manifest
- result = DependencyProxy::PullManifestService.new(image, tag, token).execute
+ result = DependencyProxy::FindOrCreateManifestService.new(group, image, tag, token).execute
if result[:status] == :success
- render json: result[:manifest]
+ send_upload(result[:manifest].file)
else
render status: result[:http_status], json: result[:message]
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 5df7ff0632a..d1b09e1b49e 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -14,6 +14,10 @@ class Groups::GroupMembersController < Groups::ApplicationController
# Authorize
before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
+ before_action do
+ push_frontend_feature_flag(:group_members_filtered_search, @group, default_enabled: true)
+ end
+
skip_before_action :check_two_factor_requirement, only: :leave
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite,
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 03d41f1dd6d..84dc570a1e9 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -5,9 +5,6 @@ class Groups::MilestonesController < Groups::ApplicationController
before_action :milestone, only: [:edit, :show, :update, :issues, :merge_requests, :participants, :labels, :destroy]
before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy]
- before_action do
- push_frontend_feature_flag(:burnup_charts, @group, default_enabled: true)
- end
feature_category :issue_tracking
diff --git a/app/controllers/groups/settings/integrations_controller.rb b/app/controllers/groups/settings/integrations_controller.rb
index a66372b3571..8903feaff04 100644
--- a/app/controllers/groups/settings/integrations_controller.rb
+++ b/app/controllers/groups/settings/integrations_controller.rb
@@ -25,10 +25,6 @@ module Groups
Service.find_or_initialize_non_project_specific_integration(name, group_id: group.id)
end
- def integrations_enabled?
- Feature.enabled?(:group_level_integrations, group, default_enabled: true)
- end
-
def scoped_edit_integration_path(integration)
edit_group_settings_integration_path(group, integration)
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 8d528e123e1..068815f7f07 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -69,7 +69,7 @@ class GroupsController < Groups::ApplicationController
@group = Groups::CreateService.new(current_user, group_params).execute
if @group.persisted?
- track_experiment_event(:onboarding_issues, 'created_namespace')
+ successful_creation_hooks
notice = if @group.chat_team.present?
"Group '#{@group.name}' and its Mattermost team were successfully created."
@@ -319,6 +319,10 @@ class GroupsController < Groups::ApplicationController
private
+ def successful_creation_hooks
+ track_experiment_event(:onboarding_issues, 'created_namespace')
+ end
+
def groups
if @group.supports_events?
@group.self_and_descendants.public_or_visible_to_user(current_user)
@@ -329,6 +333,11 @@ class GroupsController < Groups::ApplicationController
def markdown_service_params
params.merge(group: group)
end
+
+ override :has_project_list?
+ def has_project_list?
+ %w(details show index).include?(action_name)
+ end
end
GroupsController.prepend_if_ee('EE::GroupsController')
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index 78f4a0cffca..4417cfe9098 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -20,8 +20,9 @@ class Import::BulkImportsController < ApplicationController
format.json do
render json: { importable_data: serialized_importable_data }
end
-
- format.html
+ format.html do
+ @source_url = session[url_key]
+ end
end
end
@@ -57,7 +58,7 @@ class Import::BulkImportsController < ApplicationController
end
def create_params
- params.permit(:bulk_import, [*bulk_import_params])
+ params.permit(bulk_import: bulk_import_params)[:bulk_import]
end
def bulk_import_params
@@ -84,11 +85,9 @@ class Import::BulkImportsController < ApplicationController
def verify_blocked_uri
Gitlab::UrlBlocker.validate!(
session[url_key],
- **{
- allow_localhost: allow_local_requests?,
- allow_local_network: allow_local_requests?,
- schemes: %w(http https)
- }
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
clear_session_data
@@ -129,7 +128,7 @@ class Import::BulkImportsController < ApplicationController
def credentials
{
url: session[url_key],
- access_token: [access_token_key]
+ access_token: session[access_token_key]
}
end
end
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index bcbf5938e11..17f937a3dfd 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -136,11 +136,9 @@ class Import::FogbugzController < Import::BaseController
def verify_blocked_uri
Gitlab::UrlBlocker.validate!(
params[:uri],
- **{
- allow_localhost: allow_local_requests?,
- allow_local_network: allow_local_requests?,
- schemes: %w(http https)
- }
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
redirect_to new_import_fogbugz_url, alert: _('Specified URL cannot be used: "%{reason}"') % { reason: e.message }
diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb
index 4785a71b8a1..5a4eef352b8 100644
--- a/app/controllers/import/gitea_controller.rb
+++ b/app/controllers/import/gitea_controller.rb
@@ -72,11 +72,9 @@ class Import::GiteaController < Import::GithubController
def verify_blocked_uri
Gitlab::UrlBlocker.validate!(
provider_url,
- {
- allow_localhost: allow_local_requests?,
- allow_local_network: allow_local_requests?,
- schemes: %w(http https)
- }
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
session[access_token_key] = nil
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 8ac93aeb9c0..beb3e92b5ea 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -17,6 +17,8 @@ class Import::GithubController < Import::BaseController
rescue_from Octokit::TooManyRequests, with: :provider_rate_limit
rescue_from Gitlab::GithubImport::RateLimitError, with: :rate_limit_threshold_exceeded
+ PAGE_LENGTH = 25
+
def new
if !ci_cd_only? && github_import_configured? && logged_in_with_provider?
go_to_provider_for_permissions
@@ -115,19 +117,16 @@ class Import::GithubController < Import::BaseController
def client_repos
@client_repos ||= if Feature.enabled?(:remove_legacy_github_client)
- concatenated_repos
+ if sanitized_filter_param
+ client.search_repos_by_name(sanitized_filter_param, pagination_options)[:items]
+ else
+ client.octokit.repos(nil, pagination_options)
+ end
else
filtered(client.repos)
end
end
- def concatenated_repos
- return [] unless client.respond_to?(:each_page)
- return client.each_page(:repos).flat_map(&:objects) unless sanitized_filter_param
-
- client.search_repos_by_name(sanitized_filter_param).flat_map(&:objects).flat_map(&:items)
- end
-
def sanitized_filter_param
super
@@ -257,6 +256,13 @@ class Import::GithubController < Import::BaseController
def rate_limit_threshold_exceeded
head :too_many_requests
end
+
+ def pagination_options
+ {
+ page: [1, params[:page].to_i].max,
+ per_page: PAGE_LENGTH
+ }
+ end
end
Import::GithubController.prepend_if_ee('EE::Import::GithubController')
diff --git a/app/controllers/import/gitlab_groups_controller.rb b/app/controllers/import/gitlab_groups_controller.rb
index d8118477a80..f68b76a7b36 100644
--- a/app/controllers/import/gitlab_groups_controller.rb
+++ b/app/controllers/import/gitlab_groups_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Import::GitlabGroupsController < ApplicationController
- include WorkhorseImportExportUpload
+ include WorkhorseAuthorization
before_action :ensure_group_import_enabled
before_action :import_rate_limit, only: %i[create]
@@ -64,4 +64,12 @@ class Import::GitlabGroupsController < ApplicationController
redirect_to new_group_path
end
end
+
+ def uploader_class
+ ImportExportUploader
+ end
+
+ def maximum_size
+ Gitlab::CurrentSettings.max_import_size.megabytes
+ end
end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 39d053347f0..0e6b0af6baf 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Import::GitlabProjectsController < Import::BaseController
- include WorkhorseImportExportUpload
+ include WorkhorseAuthorization
before_action :whitelist_query_limiting, only: [:create]
before_action :verify_gitlab_project_import_enabled
@@ -45,4 +45,12 @@ class Import::GitlabProjectsController < Import::BaseController
def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42437')
end
+
+ def uploader_class
+ ImportExportUploader
+ end
+
+ def maximum_size
+ Gitlab::CurrentSettings.max_import_size.megabytes
+ end
end
diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb
deleted file mode 100644
index 03bde0345e3..00000000000
--- a/app/controllers/import/google_code_controller.rb
+++ /dev/null
@@ -1,123 +0,0 @@
-# frozen_string_literal: true
-
-class Import::GoogleCodeController < Import::BaseController
- before_action :verify_google_code_import_enabled
- before_action :user_map, only: [:new_user_map, :create_user_map]
-
- def new
- end
-
- def callback
- dump_file = params[:dump_file]
-
- unless dump_file.respond_to?(:read)
- return redirect_back_or_default(options: { alert: _("You need to upload a Google Takeout archive.") })
- end
-
- begin
- dump = Gitlab::Json.parse(dump_file.read)
- rescue
- return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") })
- end
-
- client = Gitlab::GoogleCodeImport::Client.new(dump)
- unless client.valid?
- return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") })
- end
-
- session[:google_code_dump] = dump
-
- if params[:create_user_map] == "1"
- redirect_to new_user_map_import_google_code_path
- else
- redirect_to status_import_google_code_path
- end
- end
-
- def new_user_map
- end
-
- def create_user_map
- user_map_json = params[:user_map]
- user_map_json = "{}" if user_map_json.blank?
-
- begin
- user_map = Gitlab::Json.parse(user_map_json)
- rescue
- flash.now[:alert] = _("The entered user map is not a valid JSON user map.")
-
- return render "new_user_map"
- end
-
- unless user_map.is_a?(Hash) && user_map.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
- flash.now[:alert] = _("The entered user map is not a valid JSON user map.")
-
- return render "new_user_map"
- end
-
- # This is the default, so let's not save it into the database.
- user_map.reject! do |key, value|
- value == Gitlab::GoogleCodeImport::Client.mask_email(key)
- end
-
- session[:google_code_user_map] = user_map
-
- flash[:notice] = _("The user map has been saved. Continue by selecting the projects you want to import.")
-
- redirect_to status_import_google_code_path
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def status
- unless client.valid?
- return redirect_to new_import_google_code_path
- end
-
- @repos = client.repos
- @incompatible_repos = client.incompatible_repos
-
- @already_added_projects = find_already_added_projects('google_code')
- already_added_projects_names = @already_added_projects.pluck(:import_source)
-
- @repos.reject! { |repo| already_added_projects_names.include? repo.name }
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def jobs
- render json: find_jobs('google_code')
- end
-
- def create
- repo = client.repo(params[:repo_id])
- user_map = session[:google_code_user_map]
-
- project = Gitlab::GoogleCodeImport::ProjectCreator.new(repo, current_user.namespace, current_user, user_map).execute
-
- if project.persisted?
- render json: ProjectSerializer.new.represent(project)
- else
- render json: { errors: project_save_error(project) }, status: :unprocessable_entity
- end
- end
-
- private
-
- def client
- @client ||= Gitlab::GoogleCodeImport::Client.new(session[:google_code_dump])
- end
-
- def verify_google_code_import_enabled
- render_404 unless google_code_import_enabled?
- end
-
- def user_map
- @user_map ||= begin
- user_map = client.user_map
-
- stored_user_map = session[:google_code_user_map]
- user_map.update(stored_user_map) if stored_user_map
-
- Hash[user_map.sort]
- end
- end
-end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 26fc1c11f6d..ad92645c23e 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -20,7 +20,6 @@ class InvitesController < ApplicationController
def accept
if member.accept_invite!(current_user)
- track_invitation_reminders_experiment('accepted')
redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") %
{ member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] }
else
@@ -107,17 +106,4 @@ class InvitesController < ApplicationController
}
end
end
-
- def track_invitation_reminders_experiment(action)
- return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
-
- property = Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group'
-
- Gitlab::Tracking.event(
- Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
- action,
- property: property,
- label: Digest::MD5.hexdigest(member.to_global_id.to_s)
- )
- end
end
diff --git a/app/controllers/jira_connect/app_descriptor_controller.rb b/app/controllers/jira_connect/app_descriptor_controller.rb
index bf53c61601b..d1ba8a98c64 100644
--- a/app/controllers/jira_connect/app_descriptor_controller.rb
+++ b/app/controllers/jira_connect/app_descriptor_controller.rb
@@ -27,29 +27,9 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
authentication: {
type: 'jwt'
},
+ modules: modules,
scopes: %w(READ WRITE DELETE),
apiVersion: 1,
- modules: {
- jiraDevelopmentTool: {
- key: 'gitlab-development-tool',
- application: {
- value: 'GitLab'
- },
- name: {
- value: 'GitLab'
- },
- url: 'https://gitlab.com',
- logoUrl: view_context.image_url('gitlab_logo.png'),
- capabilities: %w(branch commit pull_request)
- },
- postInstallPage: {
- key: 'gitlab-configuration',
- name: {
- value: 'GitLab Configuration'
- },
- url: relative_to_base_path(jira_connect_subscriptions_path)
- }
- },
apiMigrations: {
gdpr: true
}
@@ -58,6 +38,55 @@ class JiraConnect::AppDescriptorController < JiraConnect::ApplicationController
private
+ HOME_URL = 'https://gitlab.com'
+ DOC_URL = 'https://docs.gitlab.com/ee/user/project/integrations/jira.html#gitlab-jira-integration'
+
+ def modules
+ modules = {
+ jiraDevelopmentTool: {
+ key: 'gitlab-development-tool',
+ application: {
+ value: 'GitLab'
+ },
+ name: {
+ value: 'GitLab'
+ },
+ url: HOME_URL,
+ logoUrl: logo_url,
+ capabilities: %w(branch commit pull_request)
+ },
+ postInstallPage: {
+ key: 'gitlab-configuration',
+ name: {
+ value: 'GitLab Configuration'
+ },
+ url: relative_to_base_path(jira_connect_subscriptions_path)
+ }
+ }
+
+ modules.merge!(build_information_module)
+
+ modules
+ end
+
+ def logo_url
+ view_context.image_url('gitlab_logo.png')
+ end
+
+ # See: https://developer.atlassian.com/cloud/jira/software/modules/build/
+ def build_information_module
+ {
+ jiraBuildInfoProvider: {
+ homeUrl: HOME_URL,
+ logoUrl: logo_url,
+ documentationUrl: DOC_URL,
+ actions: {},
+ name: { value: "GitLab CI" },
+ key: "gitlab-ci"
+ }
+ }
+ end
+
def relative_to_base_path(full_path)
full_path.sub(/^#{jira_connect_base_path}/, '')
end
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index 5199bb25c8c..85ee2204324 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -11,7 +11,8 @@ class JwtController < ApplicationController
feature_category :authentication_and_authorization
SERVICES = {
- Auth::ContainerRegistryAuthenticationService::AUDIENCE => Auth::ContainerRegistryAuthenticationService
+ ::Auth::ContainerRegistryAuthenticationService::AUDIENCE => ::Auth::ContainerRegistryAuthenticationService,
+ ::Auth::DependencyProxyAuthenticationService::AUDIENCE => ::Auth::DependencyProxyAuthenticationService
}.freeze
def auth
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index 1e6340f285e..3a189c900ac 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class Profiles::KeysController < Profiles::ApplicationController
- skip_before_action :authenticate_user!, only: [:get_keys]
-
feature_category :users
def index
@@ -35,25 +33,6 @@ class Profiles::KeysController < Profiles::ApplicationController
end
end
- # Get all keys of a user(params[:username]) in a text format
- # Helpful for sysadmins to put in respective servers
- def get_keys
- if params[:username].present?
- begin
- user = UserFinder.new(params[:username]).find_by_username
- if user.present?
- render plain: user.all_ssh_keys.join("\n")
- else
- render_404
- end
- rescue => e
- render html: e.message
- end
- else
- render_404
- end
- end
-
private
def key_params
diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb
index 8ecf8fadefd..ebe867d915d 100644
--- a/app/controllers/projects/alert_management_controller.rb
+++ b/app/controllers/projects/alert_management_controller.rb
@@ -3,7 +3,7 @@
class Projects::AlertManagementController < Projects::ApplicationController
before_action :authorize_read_alert_management_alert!
- feature_category :alert_management
+ feature_category :incident_management
def index
end
diff --git a/app/controllers/projects/alerting/notifications_controller.rb b/app/controllers/projects/alerting/notifications_controller.rb
index a3f4d784f25..db5d91308db 100644
--- a/app/controllers/projects/alerting/notifications_controller.rb
+++ b/app/controllers/projects/alerting/notifications_controller.rb
@@ -10,7 +10,7 @@ module Projects
prepend_before_action :repository, :project_without_auth
- feature_category :alert_management
+ feature_category :incident_management
def create
token = extract_alert_manager_token(request)
@@ -31,7 +31,7 @@ module Projects
end
def notify_service
- notify_service_class.new(project, current_user, notification_payload)
+ notify_service_class.new(project, notification_payload)
end
def notify_service_class
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 02e941db636..8f16650a6f2 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -32,11 +32,6 @@ class Projects::BlobController < Projects::ApplicationController
before_action :validate_diff_params, only: :diff
before_action :set_last_commit_sha, only: [:edit, :update]
- before_action only: :show do
- push_frontend_feature_flag(:suggest_pipeline, default_enabled: true)
- push_frontend_feature_flag(:gitlab_ci_yml_preview, @project, default_enabled: false)
- end
-
track_redis_hll_event :create, :update, name: 'g_edit_by_sfe', feature: :track_editor_edit_actions, feature_default_enabled: true
feature_category :source_code_management
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index fe4502a0e06..51c9bf3699a 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -8,7 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :authorize_read_board!, only: [:index, :show]
before_action :assign_endpoint_vars
before_action do
- push_frontend_feature_flag(:boards_with_swimlanes, project, default_enabled: true)
+ push_frontend_feature_flag(:add_issues_button)
end
feature_category :boards
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index cf1efda5d13..a753d5705aa 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -18,8 +18,8 @@ class Projects::BranchesController < Projects::ApplicationController
def index
respond_to do |format|
format.html do
- @sort = params[:sort].presence || sort_value_recently_updated
@mode = params[:state].presence || 'overview'
+ @sort = sort_value_for_mode
@overview_max_branches = 5
# Fetch branches for the specified mode
@@ -42,10 +42,6 @@ class Projects::BranchesController < Projects::ApplicationController
end
end
- def recent
- @branches = @repository.recent_branches
- end
-
def diverging_commit_counts
respond_to do |format|
format.json do
@@ -129,6 +125,12 @@ class Projects::BranchesController < Projects::ApplicationController
private
+ def sort_value_for_mode
+ return params[:sort] if params[:sort].present?
+
+ 'stale' == @mode ? sort_value_oldest_updated : sort_value_recently_updated
+ end
+
# It can be expensive to calculate the diverging counts for each
# branch. Normally the frontend should be specifying a set of branch
# names, but prior to
@@ -173,19 +175,32 @@ class Projects::BranchesController < Projects::ApplicationController
end
def fetch_branches_by_mode
- if @mode == 'overview'
- # overview mode
- @active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?)
- # Here we get one more branch to indicate if there are more data we're not showing
- @active_branches = @active_branches.first(@overview_max_branches + 1)
- @stale_branches = @stale_branches.first(@overview_max_branches + 1)
- @branches = @active_branches + @stale_branches
+ return fetch_branches_for_overview if @mode == 'overview'
+
+ # active/stale/all view mode
+ @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute
+ @branches = @branches.select { |b| b.state.to_s == @mode } if %w[active stale].include?(@mode)
+ @branches = Kaminari.paginate_array(@branches).page(params[:page])
+ end
+
+ def fetch_branches_for_overview
+ # Here we get one more branch to indicate if there are more data we're not showing
+ limit = @overview_max_branches + 1
+
+ if Feature.enabled?(:branch_list_keyset_pagination, project, default_enabled: true)
+ @active_branches =
+ BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_recently_updated })
+ .execute(gitaly_pagination: true).select(&:active?)
+ @stale_branches =
+ BranchesFinder.new(@repository, { per_page: limit, sort: sort_value_oldest_updated })
+ .execute(gitaly_pagination: true).select(&:stale?)
else
- # active/stale/all view mode
- @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute
- @branches = @branches.select { |b| b.state.to_s == @mode } if %w[active stale].include?(@mode)
- @branches = Kaminari.paginate_array(@branches).page(params[:page])
+ @active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?)
+ @active_branches = @active_branches.first(limit)
+ @stale_branches = @stale_branches.first(limit)
end
+
+ @branches = @active_branches + @stale_branches
end
def confidential_issue_project
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index c2428270fa6..cc391868df0 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -2,6 +2,9 @@
class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
+ before_action do
+ push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: false)
+ end
feature_category :pipeline_authoring
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 1ddc9d567e0..ab1cf63c885 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -17,8 +17,6 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
def show
@cycle_analytics = ::CycleAnalytics::ProjectLevel.new(@project, options: options(cycle_analytics_project_params))
- @cycle_analytics_no_data = @cycle_analytics.no_stats?
-
respond_to do |format|
format.html do
Gitlab::UsageDataCounters::CycleAnalyticsCounter.count(:views)
diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb
index 9142f769b28..da9dcd1c09c 100644
--- a/app/controllers/projects/feature_flags_controller.rb
+++ b/app/controllers/projects/feature_flags_controller.rb
@@ -14,7 +14,6 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:feature_flag_permissions)
- push_frontend_feature_flag(:feature_flags_new_version, project, default_enabled: true)
push_frontend_feature_flag(:feature_flags_legacy_read_only, project, default_enabled: true)
push_frontend_feature_flag(:feature_flags_legacy_read_only_override, project)
end
@@ -101,15 +100,7 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
protected
def feature_flag
- @feature_flag ||= @noteable = if new_version_feature_flags_enabled?
- project.operations_feature_flags.find_by_iid!(params[:iid])
- else
- project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid])
- end
- end
-
- def new_version_feature_flags_enabled?
- ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
+ @feature_flag ||= @noteable = project.operations_feature_flags.find_by_iid!(params[:iid])
end
def ensure_legacy_flags_writable!
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 3a1b4f380a2..3a0e40f9745 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -44,14 +44,14 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
- push_frontend_feature_flag(:vue_issue_header, @project, default_enabled: true)
+ push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true)
end
before_action only: :show do
real_time_feature_flag = :real_time_issue_sidebar
real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project)
- push_to_gon_features(real_time_feature_flag, real_time_enabled)
+ push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled)
record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
@@ -59,6 +59,10 @@ class Projects::IssuesController < Projects::ApplicationController
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
+ before_action :run_null_hypothesis_experiment,
+ only: [:index, :new, :create],
+ if: -> { Feature.enabled?(:gitlab_experiments) }
+
respond_to :html
alias_method :designs, :show
@@ -74,6 +78,8 @@ class Projects::IssuesController < Projects::ApplicationController
feature_category :service_desk, [:service_desk]
feature_category :importers, [:import_csv, :export_csv]
+ attr_accessor :vulnerability_id
+
def index
@issues = @issuables
@@ -125,6 +131,8 @@ class Projects::IssuesController < Projects::ApplicationController
service = ::Issues::CreateService.new(project, current_user, create_params)
@issue = service.execute
+ create_vulnerability_issue_link(issue)
+
if service.discussions_to_resolve.count(&:resolved?) > 0
flash[:notice] = if service.discussion_to_resolve_id
_("Resolved 1 discussion.")
@@ -385,6 +393,17 @@ class Projects::IssuesController < Projects::ApplicationController
def service_desk?
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_link(issue); end
end
Projects::IssuesController.prepend_if_ee('EE::Projects::IssuesController')
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 07e38c80291..900ebc61856 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -6,6 +6,7 @@ class Projects::JobsController < Projects::ApplicationController
before_action :find_job_as_build, except: [:index, :play]
before_action :find_job_as_processable, only: [:play]
+ before_action :authorize_read_build_trace!, only: [:trace, :raw]
before_action :authorize_read_build!
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :erase]
@@ -14,8 +15,8 @@ class Projects::JobsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
- before_action do
- push_frontend_feature_flag(:ci_job_line_links, @project)
+ before_action only: :index do
+ frontend_experimentation_tracking_data(:jobs_empty_state, 'click_button')
end
layout 'project'
@@ -157,6 +158,18 @@ class Projects::JobsController < Projects::ApplicationController
private
+ def authorize_read_build_trace!
+ return if can?(current_user, :read_build_trace, @build)
+
+ msg = _(
+ "You must have developer or higher permissions in the associated project to view job logs when debug trace is enabled. To disable debug trace, set the 'CI_DEBUG_TRACE' variable to 'false' in your pipeline configuration or CI/CD settings. " \
+ "If you need to view this job log, a project maintainer must add you to the project with developer permissions or higher."
+ )
+ return access_denied!(msg) if @build.debug_mode?
+
+ access_denied!(_('The current user is not authorized to access the job log.'))
+ end
+
def authorize_update_build!
return access_denied! unless can?(current_user, :update_build, @build)
end
@@ -204,11 +217,7 @@ class Projects::JobsController < Projects::ApplicationController
end
def find_job_as_processable
- if ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
- @build = project.processables.find(params[:id])
- else
- find_job_as_build
- end
+ @build = project.processables.find(params[:id])
end
def build_path(build)
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 3e077c1af37..7d3e7759081 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -11,6 +11,12 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
before_action :apply_diff_view_cookie!, only: [:diffs, :diff_for_path]
before_action :build_merge_request, except: [:create]
+ before_action do
+ push_frontend_feature_flag(:merge_request_reviewers, @project, default_enabled: true)
+ push_frontend_feature_flag(:mr_collapsed_approval_rules, @project)
+ push_frontend_feature_flag(:reviewer_approval_rules, @project)
+ end
+
def new
define_new_vars
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 7fbeac12644..da19ddf6105 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -69,7 +69,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
}
options = additional_attributes.merge(
- diff_view: unified_diff_lines_view_type(@merge_request.project),
+ diff_view: "inline",
merge_ref_head_diff: render_merge_ref_head_diff?
)
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index f2b41294a85..382fbfaac25 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -21,13 +21,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
:exposed_artifacts,
:coverage_reports,
:terraform_reports,
- :accessibility_reports
+ :accessibility_reports,
+ :codequality_reports
]
before_action :set_issuables_index, only: [:index]
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show] do
- push_frontend_feature_flag(:suggest_pipeline, default_enabled: true)
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true)
push_frontend_feature_flag(:multiline_comments, @project, default_enabled: true)
@@ -36,13 +36,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true)
push_frontend_feature_flag(:merge_request_widget_graphql, @project)
- push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true)
push_frontend_feature_flag(:unified_diff_components, @project)
- push_frontend_feature_flag(:highlight_current_diff_row, @project)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true)
+ push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
+ push_frontend_feature_flag(:core_security_mr_widget_downloads, @project, default_enabled: true)
push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
push_frontend_feature_flag(:test_failure_history, @project)
+ push_frontend_feature_flag(:diffs_gradual_load, @project, default_enabled: true)
record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
@@ -50,6 +51,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
+ push_frontend_feature_flag(:merge_request_reviewers, @project, default_enabled: true)
+ push_frontend_feature_flag(:mr_collapsed_approval_rules, @project)
+ push_frontend_feature_flag(:reviewer_approval_rules, @project)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
@@ -98,9 +102,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@noteable = @merge_request
@commits_count = @merge_request.commits_count + @merge_request.context_commits_count
@issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
- @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json
+ @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestCurrentUserEntity).to_json
@show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs
- @file_by_file_default = Feature.enabled?(:view_diffs_file_by_file, default_enabled: true) && current_user&.view_diffs_file_by_file
+ @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?
@endpoint_metadata_url = endpoint_metadata_url(@project, @merge_request)
@@ -193,6 +197,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
end
+ def codequality_reports
+ reports_response(@merge_request.compare_codequality_reports)
+ end
+
def terraform_reports
reports_response(@merge_request.find_terraform_reports)
end
@@ -481,7 +489,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def endpoint_metadata_url(project, merge_request)
params = request.query_parameters
- params[:view] = unified_diff_lines_view_type(project)
+ params[:view] = "inline"
if Feature.enabled?(:default_merge_ref_for_diffs, project)
params = params.merge(diff_head: true)
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 31189c888b7..dcd3c49441e 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -6,9 +6,6 @@ class Projects::MilestonesController < Projects::ApplicationController
before_action :check_issuables_available!
before_action :milestone, only: [:edit, :update, :destroy, :show, :issues, :merge_requests, :participants, :labels, :promote]
- before_action do
- push_frontend_feature_flag(:burnup_charts, @project, default_enabled: true)
- end
# Allow read any milestone
before_action :authorize_read_milestone!
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index f71a92ee874..74513da8675 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -17,7 +17,8 @@ class Projects::PipelinesController < Projects::ApplicationController
push_frontend_feature_flag(:new_pipeline_form, project, default_enabled: true)
push_frontend_feature_flag(:graphql_pipeline_header, project, type: :development, default_enabled: false)
push_frontend_feature_flag(:graphql_pipeline_details, project, type: :development, default_enabled: false)
- push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development)
+ push_frontend_feature_flag(:graphql_pipeline_analytics, project, type: :development)
+ push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development, default_enabled: true)
end
before_action :ensure_pipeline, only: [:show]
@@ -39,7 +40,7 @@ class Projects::PipelinesController < Projects::ApplicationController
.new(project, current_user, index_params)
.execute
.page(params[:page])
- .per(30)
+ .per(20)
@pipelines_count = limited_pipelines_count(project)
@@ -185,12 +186,15 @@ class Projects::PipelinesController < Projects::ApplicationController
def charts
@charts = {}
+ @counts = {}
+
+ return if Feature.enabled?(:graphql_pipeline_analytics)
+
@charts[:week] = Gitlab::Ci::Charts::WeekChart.new(project)
@charts[:month] = Gitlab::Ci::Charts::MonthChart.new(project)
@charts[:year] = Gitlab::Ci::Charts::YearChart.new(project)
@charts[:pipeline_times] = Gitlab::Ci::Charts::PipelineTime.new(project)
- @counts = {}
@counts[:total] = @project.all_pipelines.count(:all)
@counts[:success] = @project.all_pipelines.success.count(:all)
@counts[:failed] = @project.all_pipelines.failed.count(:all)
@@ -214,7 +218,9 @@ class Projects::PipelinesController < Projects::ApplicationController
def config_variables
respond_to do |format|
format.json do
- render json: Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha])
+ result = Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha])
+
+ result.nil? ? head(:no_content) : render(json: result)
end
end
end
diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb
index 2892542e63c..19c908026cf 100644
--- a/app/controllers/projects/prometheus/alerts_controller.rb
+++ b/app/controllers/projects/prometheus/alerts_controller.rb
@@ -16,7 +16,7 @@ module Projects
before_action :authorize_read_prometheus_alerts!, except: [:notify]
before_action :alert, only: [:update, :show, :destroy, :metrics_dashboard]
- feature_category :alert_management
+ feature_category :incident_management
def index
render json: serialize_as_json(alerts)
@@ -73,7 +73,7 @@ module Projects
def notify_service
Projects::Prometheus::Alerts::NotifyService
- .new(project, current_user, params.permit!)
+ .new(project, params.permit!)
end
def create_service
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index d8ba7e4f235..8be7af3e2c5 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -10,29 +10,31 @@ class Projects::RawController < Projects::ApplicationController
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) }
+ before_action :set_ref_and_path
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :show_rate_limit, only: [:show], unless: :external_storage_request?
- before_action :assign_ref_vars
before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled?
feature_category :source_code_management
def show
- @blob = @repository.blob_at(@commit.id, @path)
+ @blob = @repository.blob_at(@ref, @path)
send_blob(@repository, @blob, inline: (params[:inline] != 'false'), allow_caching: @project.public?)
end
private
- def show_rate_limit
+ def set_ref_and_path
# This bypasses assign_ref_vars to avoid a Gitaly FindCommit lookup.
- # When rate limiting, we really don't care if a different commit is
- # being requested.
- _ref, path = extract_ref(get_id)
+ # We don't need to find the commit to either rate limit or send the
+ # blob.
+ @ref, @path = extract_ref(get_id)
+ end
- if rate_limiter.throttled?(:show_raw_controller, scope: [@project, path], threshold: raw_blob_request_limit)
+ def show_rate_limit
+ if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @path], threshold: raw_blob_request_limit)
rate_limiter.log_request(request, :raw_blob_request_limit, current_user)
render plain: _('You cannot access the raw file. Please wait a minute.'), status: :too_many_requests
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 24fa0894a9c..b7a5a63e642 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -53,12 +53,23 @@ class Projects::RunnersController < Projects::ApplicationController
def toggle_shared_runners
if !project.shared_runners_enabled && project.group && project.group.shared_runners_setting == 'disabled_and_unoverridable'
- return redirect_to project_runners_path(@project), alert: _("Cannot enable shared runners because parent group does not allow it")
+
+ if Feature.enabled?(:vueify_shared_runners_toggle, @project)
+ render json: { error: _('Cannot enable shared runners because parent group does not allow it') }, status: :unauthorized
+ else
+ redirect_to project_runners_path(@project), alert: _('Cannot enable shared runners because parent group does not allow it')
+ end
+
+ return
end
project.toggle!(:shared_runners_enabled)
- redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
+ if Feature.enabled?(:vueify_shared_runners_toggle, @project)
+ render json: {}, status: :ok
+ else
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-runners-settings')
+ end
end
def toggle_group_runners
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index f76278a12a4..31533dfeea0 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -11,6 +11,7 @@ module Projects
before_action :define_variables
before_action do
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
+ push_frontend_feature_flag(:vueify_shared_runners_toggle, @project)
end
helper_method :highlight_badge
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index c9386a2edec..f8155b77e60 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -7,7 +7,6 @@ module Projects
before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token]
before_action do
- push_frontend_feature_flag(:http_integrations_list, @project)
push_frontend_feature_flag(:multiple_http_integrations_custom_mapping, @project)
end
diff --git a/app/controllers/projects/static_site_editor_controller.rb b/app/controllers/projects/static_site_editor_controller.rb
index 5c3d9b60877..0d9a6f568a1 100644
--- a/app/controllers/projects/static_site_editor_controller.rb
+++ b/app/controllers/projects/static_site_editor_controller.rb
@@ -19,6 +19,10 @@ class Projects::StaticSiteEditorController < Projects::ApplicationController
feature_category :static_site_editor
+ def index
+ render_404
+ end
+
def show
service_response = ::StaticSiteEditor::ConfigService.new(
container: project,
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 8f794512486..d1486f765e4 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -6,7 +6,4 @@ class Projects::WikisController < Projects::ApplicationController
alias_method :container, :project
feature_category :wiki
-
- def git_access
- end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index c03a820b384..3744517934a 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -19,9 +19,6 @@ class ProjectsController < Projects::ApplicationController
before_action :redirect_git_extension, only: [:show]
before_action :project, except: [:index, :new, :create, :resolve]
before_action :repository, except: [:index, :new, :create, :resolve]
- before_action :assign_ref_vars, if: -> { action_name == 'show' && repo_exists? }
- before_action :tree,
- if: -> { action_name == 'show' && repo_exists? && project_view_files? }
before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
before_action :present_project, only: [:edit]
before_action :authorize_download_code!, only: [:refs]
@@ -34,15 +31,9 @@ class ProjectsController < Projects::ApplicationController
# Project Export Rate Limit
before_action :export_rate_limit, only: [:export, :download_export, :generate_new_export]
- # Experiments
- before_action only: [:new, :create] do
- frontend_experimentation_tracking_data(:new_create_project_ui, 'click_tab')
- push_frontend_experiment(:new_create_project_ui)
- end
-
before_action only: [:edit] do
- push_frontend_feature_flag(:service_desk_custom_address, @project)
push_frontend_feature_flag(:approval_suggestions, @project, default_enabled: true)
+ push_frontend_feature_flag(:allow_editing_commit_messages, @project)
end
layout :determine_layout
@@ -80,8 +71,6 @@ class ProjectsController < Projects::ApplicationController
@project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
- cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.zone.at(0) }
-
redirect_to(
project_path(@project, custom_import_params),
notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name }
@@ -147,6 +136,8 @@ class ProjectsController < Projects::ApplicationController
end
def show
+ @id, @ref, @path = extract_ref_path
+
if @project.import_in_progress?
redirect_to project_import_path(@project, custom_import_params)
return
@@ -334,7 +325,11 @@ class ProjectsController < Projects::ApplicationController
if can?(current_user, :download_code, @project)
return render 'projects/no_repo' unless @project.repository_exists?
- render 'projects/empty' if @project.empty_repo?
+ if @project.empty_repo?
+ record_experiment_user(:invite_members_empty_project_version_a)
+
+ render 'projects/empty'
+ end
else
if can?(current_user, :read_wiki, @project)
@wiki = @project.wiki
@@ -392,6 +387,8 @@ class ProjectsController < Projects::ApplicationController
wiki_access_level
pages_access_level
metrics_dashboard_access_level
+ analytics_access_level
+ operations_access_level
]
end
@@ -435,6 +432,7 @@ class ProjectsController < Projects::ApplicationController
project_setting_attributes: %i[
show_default_award_emojis
squash_option
+ allow_editing_commit_messages
]
] + [project_feature_attributes: project_feature_attributes]
end
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index 5b3f78a92ad..4a6fef56ef5 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -45,7 +45,7 @@ module Registrations
end
def update_params
- params.require(:user).permit(:role, :setup_for_company)
+ params.require(:user).permit(:role, :other_role, :setup_for_company)
end
def requires_confirmation?(user)
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 04cb9616cf6..e7872eeac27 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -6,8 +6,6 @@ class RegistrationsController < Devise::RegistrationsController
include RecaptchaHelper
include InvisibleCaptchaOnSignup
- BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze
-
layout 'devise'
prepend_before_action :check_captcha, only: :create
@@ -167,12 +165,18 @@ class RegistrationsController < Devise::RegistrationsController
end
def set_user_state
- return unless Gitlab::CurrentSettings.require_admin_approval_after_user_signup
+ return unless set_blocked_pending_approval?
+
+ resource.state = User::BLOCKED_PENDING_APPROVAL_STATE
+ end
- resource.state = BLOCKED_PENDING_APPROVAL_STATE
+ def set_blocked_pending_approval?
+ Gitlab::CurrentSettings.require_admin_approval_after_user_signup
end
def set_invite_params
@invite_email = ActionController::Base.helpers.sanitize(params[:invite_email])
end
end
+
+RegistrationsController.prepend_if_ee('EE::RegistrationsController')
diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb
index ec854bd0dde..a5b81054ee4 100644
--- a/app/controllers/repositories/git_http_client_controller.rb
+++ b/app/controllers/repositories/git_http_client_controller.rb
@@ -87,8 +87,12 @@ module Repositories
@project
end
+ def repository_path
+ @repository_path ||= params[:repository_path]
+ end
+
def parse_repo_path
- @container, @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:repository_id]}")
+ @container, @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse(repository_path)
end
def render_missing_personal_access_token
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index aa6609bef2a..3cf0a23b7f6 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -80,6 +80,8 @@ module Repositories
return if Gitlab::Database.read_only?
return unless repo_type.project?
+ OnboardingProgressService.new(project.namespace).execute(action: :git_read)
+
if Feature.enabled?(:project_statistics_sync, project, default_enabled: true)
Projects::FetchStatisticsIncrementService.new(project).execute
else
@@ -90,7 +92,6 @@ module Repositories
def access
@access ||= access_klass.new(access_actor, container, 'http',
authentication_abilities: authentication_abilities,
- namespace_path: params[:namespace_id],
repository_path: repository_path,
redirected_path: redirected_path,
auth_result_type: auth_result_type)
@@ -113,10 +114,6 @@ module Repositories
@access_klass ||= repo_type.access_checker_class
end
- def repository_path
- @repository_path ||= params[:repository_id].sub(/\.git$/, '')
- end
-
def log_user_activity
Users::ActivityService.new(user).execute
end
diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb
index 96185608c09..248323a0bb5 100644
--- a/app/controllers/repositories/lfs_api_controller.rb
+++ b/app/controllers/repositories/lfs_api_controller.rb
@@ -92,16 +92,26 @@ module Repositories
{
upload: {
href: "#{project.http_url_to_repo}/gitlab-lfs/objects/#{object[:oid]}/#{object[:size]}",
- header: {
- Authorization: authorization_header,
- # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This
- # ensures that Workhorse can intercept the request.
- 'Content-Type': LFS_TRANSFER_CONTENT_TYPE
- }.compact
+ header: upload_headers
}
}
end
+ def upload_headers
+ headers = {
+ Authorization: authorization_header,
+ # git-lfs v2.5.0 sets the Content-Type based on the uploaded file. This
+ # ensures that Workhorse can intercept the request.
+ 'Content-Type': LFS_TRANSFER_CONTENT_TYPE
+ }
+
+ if Feature.enabled?(:lfs_chunked_encoding, project, default_enabled: true)
+ headers['Transfer-Encoding'] = 'chunked'
+ end
+
+ headers
+ end
+
def lfs_check_batch_operation!
if batch_operation_disallowed?
render(
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index c92b3457640..196b1887ca7 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -3,16 +3,8 @@
class SearchController < ApplicationController
include ControllerWithCrossProjectAccessCheck
include SearchHelper
- include RendersCommits
include RedisTracking
- SCOPE_PRELOAD_METHOD = {
- projects: :with_web_entity_associations,
- issues: :with_web_entity_associations,
- merge_requests: :with_web_entity_associations,
- epics: :with_web_entity_associations
- }.freeze
-
track_redis_hll_event :show, name: 'i_search_total', feature: :search_track_unique_users, feature_default_enabled: true
around_action :allow_gitaly_ref_name_caching
@@ -41,14 +33,12 @@ class SearchController < ApplicationController
@search_term = params[:search]
@sort = params[:sort] || default_sort
- @scope = search_service.scope
- @show_snippets = search_service.show_snippets?
- @search_results = search_service.search_results
- @search_objects = search_service.search_objects(preload_method)
- @search_highlight = search_service.search_highlight
-
- render_commits if @scope == 'commits'
- eager_load_user_status if @scope == 'users'
+ @search_service = Gitlab::View::Presenter::Factory.new(search_service, current_user: current_user).fabricate!
+ @scope = @search_service.scope
+ @show_snippets = @search_service.show_snippets?
+ @search_results = @search_service.search_results
+ @search_objects = @search_service.search_objects
+ @search_highlight = @search_service.search_highlight
increment_search_counters
end
@@ -79,10 +69,6 @@ class SearchController < ApplicationController
private
- def preload_method
- SCOPE_PRELOAD_METHOD[@scope.to_sym]
- end
-
# overridden in EE
def default_sort
'created_desc'
@@ -102,14 +88,6 @@ class SearchController < ApplicationController
true
end
- def render_commits
- @search_objects = prepare_commits_for_rendering(@search_objects)
- end
-
- def eager_load_user_status
- @search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
- end
-
def check_single_commit_result?
return false if params[:force_search_results]
return false unless @project.present?
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 6692c285335..2c827292928 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -27,6 +27,10 @@ class UploadsController < ApplicationController
feature_category :not_owned
+ def self.model_classes
+ MODEL_CLASSES
+ end
+
def uploader_class
PersonalFileUploader
end
@@ -99,7 +103,7 @@ class UploadsController < ApplicationController
end
def upload_model_class
- MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError)
+ self.class.model_classes[params[:model]] || raise(UnknownUploadModelError)
end
def upload_model_class_has_mounts?
@@ -112,3 +116,5 @@ class UploadsController < ApplicationController
upload_model_class.uploader_options.has_key?(upload_mount)
end
end
+
+UploadsController.prepend_if_ee('EE::UploadsController')
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 05573255066..46245286820 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -33,18 +33,36 @@ class UsersController < ApplicationController
end
format.json do
+ # In 13.8, this endpoint will be removed:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/289972
load_events
pager_json("events/_events", @events.count, events: @events)
end
end
end
+ # Get all keys of a user(params[:username]) in a text format
+ # Helpful for sysadmins to put in respective servers
+ def ssh_keys
+ render plain: user.all_ssh_keys.join("\n")
+ end
+
def activity
respond_to do |format|
format.html { render 'show' }
+
+ format.json do
+ load_events
+ pager_json("events/_events", @events.count, events: @events)
+ end
end
end
+ # Get all gpg keys of a user(params[:username]) in a text format
+ def gpg_keys
+ render plain: user.gpg_keys.select(&:verified?).map(&:key).join("\n")
+ end
+
def groups
load_groups
diff --git a/app/controllers/whats_new_controller.rb b/app/controllers/whats_new_controller.rb
index 384c984089a..cba86c65848 100644
--- a/app/controllers/whats_new_controller.rb
+++ b/app/controllers/whats_new_controller.rb
@@ -1,18 +1,19 @@
# frozen_string_literal: true
class WhatsNewController < ApplicationController
- include Gitlab::WhatsNew
+ include Gitlab::Utils::StrongMemoize
skip_before_action :authenticate_user!
- before_action :check_feature_flag, :check_valid_page_param, :set_pagination_headers
+ before_action :check_feature_flag
+ before_action :check_valid_page_param, :set_pagination_headers, unless: -> { has_version_param? }
feature_category :navigation
def index
respond_to do |format|
format.js do
- render json: whats_new_release_items(page: current_page)
+ render json: highlight_items
end
end
end
@@ -27,18 +28,29 @@ class WhatsNewController < ApplicationController
render_404 if current_page < 1
end
- def set_pagination_headers
- response.set_header('X-Next-Page', next_page)
- end
-
def current_page
params[:page]&.to_i || 1
end
- def next_page
- next_page = current_page + 1
- next_index = next_page - 1
+ def highlights
+ strong_memoize(:highlights) do
+ if has_version_param?
+ ReleaseHighlight.for_version(version: params[:version])
+ else
+ ReleaseHighlight.paginated(page: current_page)
+ end
+ end
+ end
+
+ def highlight_items
+ highlights.map {|item| Gitlab::WhatsNew::ItemPresenter.present(item) }
+ end
+
+ def set_pagination_headers
+ response.set_header('X-Next-Page', highlights.next_page)
+ end
- next_page if whats_new_file_paths[next_index]
+ def has_version_param?
+ params[:version].present?
end
end
diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb
new file mode 100644
index 00000000000..e8f7d22bf77
--- /dev/null
+++ b/app/experiments/application_experiment.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+class ApplicationExperiment < Gitlab::Experiment
+ def publish(_result)
+ track(:assignment) # track that we've assigned a variant for this context
+ Gon.global.push({ experiment: { name => signature } }, true) # push to client
+ end
+
+ def track(action, **event_args)
+ return if excluded? # no events for opted out actors or excluded subjects
+
+ 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
+ )
+ ))
+ end
+
+ private
+
+ def resolve_variant_name
+ variant_names.first if Feature.enabled?(name, self, type: :experiment)
+ end
+
+ # Cache is an implementation on top of Gitlab::Redis::SharedState that also
+ # adheres to the ActiveSupport::Cache::Store interface and uses the redis
+ # hash data type.
+ #
+ # Since Gitlab::Experiment can use any type of caching layer, utilizing the
+ # long lived shared state interface here gives us an efficient way to store
+ # context keys and the variant they've been assigned -- while also giving us
+ # a simple way to clean up an experiments data upon resolution.
+ #
+ # The data structure:
+ # key: experiment.name
+ # fields: context key => variant name
+ #
+ # The keys are expected to be `experiment_name:context_key`, which is the
+ # default cache key strategy. So running `cache.fetch("foo:bar", "value")`
+ # would create/update a hash with the key of "foo", with a field named
+ # "bar" that has "value" assigned to it.
+ class Cache < ActiveSupport::Cache::Store
+ # Clears the entire cache for a given experiment. Be careful with this
+ # since it would reset all resolved variants for the entire experiment.
+ def clear(key:)
+ key = hkey(key)[0] # extract only the first part of the key
+ pool do |redis|
+ case redis.type(key)
+ when 'hash', 'none' then redis.del(key)
+ else raise ArgumentError, 'invalid call to clear a non-hash cache key'
+ end
+ end
+ end
+
+ private
+
+ def pool
+ raise ArgumentError, 'missing block' unless block_given?
+
+ Gitlab::Redis::SharedState.with { |redis| yield redis }
+ end
+
+ def hkey(key)
+ key.split(':') # this assumes the default strategy in gitlab-experiment
+ end
+
+ def read_entry(key, **options)
+ value = pool { |redis| redis.hget(*hkey(key)) }
+ value.nil? ? nil : ActiveSupport::Cache::Entry.new(value)
+ end
+
+ def write_entry(key, entry, **options)
+ return false unless Feature.enabled?(:caching_experiments)
+ return false if entry.value.blank? # don't cache any empty values
+
+ pool { |redis| redis.hset(*hkey(key), entry.value) }
+ end
+
+ def delete_entry(key, **options)
+ pool { |redis| redis.hdel(*hkey(key)) }
+ end
+ end
+end
diff --git a/app/finders/alert_management/alerts_finder.rb b/app/finders/alert_management/alerts_finder.rb
index 1d6f790af31..be3b329fb6f 100644
--- a/app/finders/alert_management/alerts_finder.rb
+++ b/app/finders/alert_management/alerts_finder.rb
@@ -18,6 +18,7 @@ module AlertManagement
return AlertManagement::Alert.none unless authorized?
collection = project.alert_management_alerts
+ collection = by_domain(collection)
collection = by_status(collection)
collection = by_iid(collection)
collection = by_assignee(collection)
@@ -30,6 +31,10 @@ module AlertManagement
attr_reader :current_user, :project, :params
+ def by_domain(collection)
+ collection.with_operations_alerts
+ end
+
def by_iid(collection)
return collection unless params[:iid]
@@ -59,3 +64,5 @@ module AlertManagement
end
end
end
+
+AlertManagement::AlertsFinder.prepend_if_ee('EE::AlertManagement::AlertsFinder')
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 ec41d9d2c45..ef97ccb4c0f 100644
--- a/app/finders/ci/daily_build_group_report_results_finder.rb
+++ b/app/finders/ci/daily_build_group_report_results_finder.rb
@@ -4,7 +4,7 @@ module Ci
class DailyBuildGroupReportResultsFinder
include Gitlab::Allowable
- def initialize(current_user:, project:, ref_path:, start_date:, end_date:, limit: nil)
+ def initialize(current_user:, project:, ref_path: nil, start_date:, end_date:, limit: nil)
@current_user = current_user
@project = project
@ref_path = ref_path
@@ -35,11 +35,18 @@ module Ci
end
def query_params
- {
+ params = {
project_id: project,
- ref_path: ref_path,
date: start_date..end_date
}
+
+ if ref_path.present?
+ params[:ref_path] = ref_path
+ else
+ params[:default_branch] = true
+ end
+
+ params
end
def none
diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb
index 7347a83d294..a77faebb160 100644
--- a/app/finders/ci/pipelines_finder.rb
+++ b/app/finders/ci/pipelines_finder.rb
@@ -18,7 +18,9 @@ module Ci
return Ci::Pipeline.none
end
- items = pipelines.no_child
+ items = pipelines
+ items = items.no_child unless params[:iids].present?
+ items = by_iids(items)
items = by_scope(items)
items = by_status(items)
items = by_ref(items)
@@ -52,6 +54,14 @@ module Ci
project.repository.tag_names
end
+ def by_iids(items)
+ if params[:iids].present?
+ items.for_iid(params[:iids])
+ else
+ items
+ end
+ end
+
def by_scope(items)
case params[:scope]
when 'running'
diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb
index 93d139652b9..da8dfc2579a 100644
--- a/app/finders/ci/pipelines_for_merge_request_finder.rb
+++ b/app/finders/ci/pipelines_for_merge_request_finder.rb
@@ -5,8 +5,6 @@ module Ci
class PipelinesForMergeRequestFinder
include Gitlab::Utils::StrongMemoize
- EVENT = 'merge_request_event'
-
def initialize(merge_request, current_user)
@merge_request = merge_request
@current_user = current_user
@@ -36,7 +34,11 @@ module Ci
pipelines =
if merge_request.persisted?
- pipelines_using_cte
+ if Feature.enabled?(:ci_pipelines_for_merge_request_finder_new_cte, target_project)
+ pipelines_using_cte
+ else
+ pipelines_using_legacy_cte
+ end
else
triggered_for_branch.for_sha(commit_shas)
end
@@ -47,7 +49,7 @@ module Ci
private
- def pipelines_using_cte
+ def pipelines_using_legacy_cte
cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha))
source_sha_join = cte.table[:sha].eq(Ci::Pipeline.arel_table[:source_sha])
@@ -59,6 +61,16 @@ module Ci
.from_union([merged_result_pipelines, detached_merge_request_pipelines, pipelines_for_branch])
end
+ def pipelines_using_cte
+ cte = Gitlab::SQL::CTE.new(:shas, merge_request.all_commits.select(:sha))
+
+ pipelines_for_merge_requests = triggered_by_merge_request
+ pipelines_for_branch = filter_by_sha(triggered_for_branch, cte)
+
+ Ci::Pipeline.with(cte.to_arel) # rubocop: disable CodeReuse/ActiveRecord
+ .from_union([pipelines_for_merge_requests, pipelines_for_branch])
+ end
+
def filter_by_sha(pipelines, cte)
hex = Arel::Nodes::SqlLiteral.new("'hex'")
string_sha = Arel::Nodes::NamedFunction.new('encode', [cte.table[:sha], hex])
@@ -84,14 +96,7 @@ module Ci
end
def triggered_for_branch
- source_project.ci_pipelines
- .where(source: branch_pipeline_sources, ref: source_branch, tag: false) # rubocop: disable CodeReuse/ActiveRecord
- end
-
- def branch_pipeline_sources
- strong_memoize(:branch_pipeline_sources) do
- Ci::Pipeline.sources.reject { |source| source == EVENT }.values
- end
+ source_project.all_pipelines.ci_branch_sources.for_branch(source_branch)
end
def sort(pipelines)
diff --git a/app/finders/feature_flags_finder.rb b/app/finders/feature_flags_finder.rb
index 9cb3bf7fa23..7b38841970d 100644
--- a/app/finders/feature_flags_finder.rb
+++ b/app/finders/feature_flags_finder.rb
@@ -24,11 +24,7 @@ class FeatureFlagsFinder
private
def feature_flags
- if Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
- project.operations_feature_flags
- else
- project.operations_feature_flags.legacy_flag
- end
+ project.operations_feature_flags
end
def by_scope(items)
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index 1f6829a97d6..18ccea330af 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -176,7 +176,7 @@ class GroupDescendantsFinder
end
def sort
- params.fetch(:sort, 'created_desc')
+ params.fetch(:sort, 'name_asc')
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index 09283f061c0..2417b1e0771 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
class GroupMembersFinder < UnionFinder
+ RELATIONS = %i(direct inherited descendants).freeze
+ DEFAULT_RELATIONS = %i(direct inherited).freeze
+
include CreatedAtFilter
# Params can be any of the following:
@@ -17,11 +20,11 @@ class GroupMembersFinder < UnionFinder
@params = params
end
- def execute(include_relations: [:inherited, :direct])
+ def execute(include_relations: DEFAULT_RELATIONS)
group_members = group_members_list
relations = []
- return group_members if include_relations == [:direct]
+ return filter_members(group_members) if include_relations == [:direct]
relations << group_members if include_relations.include?(:direct)
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index d431c3e3699..922b53b514d 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -339,15 +339,6 @@ class IssuableFinder
cte << items
items = klass.with(cte.to_arel).from(klass.table_name)
- elsif Feature.enabled?(:pg_hint_plan_for_issuables, params.project)
- items = items.optimizer_hints(<<~HINTS)
- BitmapScan(
- issues idx_issues_on_project_id_and_created_at_and_id_and_state_id
- idx_issues_on_project_id_and_due_date_and_id_and_state_id
- idx_issues_on_project_id_and_updated_at_and_id_and_state_id
- index_issues_on_project_id_and_iid
- )
- HINTS
end
items.full_search(search, matched_columns: params[:in], use_minimum_char_limit: !use_cte_for_search?)
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index 8a194f34f74..b481afee338 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -257,6 +257,10 @@ class IssuableFinder
params.merge!(other)
end
+ def parent
+ project || group
+ end
+
private
def projects_public_or_visible_to_user
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index 013ed03a789..1ff2ad01b63 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
class MembersFinder
+ RELATIONS = %i(direct inherited descendants invited_groups).freeze
+ DEFAULT_RELATIONS = %i(direct inherited).freeze
+
# Params can be any of the following:
# sort: string
# search: string
@@ -13,7 +16,7 @@ class MembersFinder
@params = params
end
- def execute(include_relations: [:inherited, :direct])
+ def execute(include_relations: DEFAULT_RELATIONS)
members = find_members(include_relations)
filter_members(members)
@@ -56,7 +59,7 @@ class MembersFinder
def group_union_members(include_relations)
[].tap do |members|
members << direct_group_members(include_relations.include?(:descendants)) if group
- members << project_invited_groups_members if include_relations.include?(:invited_groups_members)
+ members << project_invited_groups if include_relations.include?(:invited_groups)
end
end
@@ -66,7 +69,7 @@ class MembersFinder
GroupMembersFinder.new(group).execute(include_relations: requested_relations).non_invite.non_minimal_access # rubocop: disable CodeReuse/Finder
end
- def project_invited_groups_members
+ def project_invited_groups
invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy
.new(project.invited_groups)
.base_and_ancestors
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 1f847b09752..978550aedaf 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -41,6 +41,8 @@ class MergeRequestsFinder < IssuableFinder
:environment,
:merged_after,
:merged_before,
+ :reviewer_id,
+ :reviewer_username,
:target_branch,
:wip
]
@@ -54,6 +56,10 @@ class MergeRequestsFinder < IssuableFinder
MergeRequest
end
+ def params_class
+ MergeRequestsFinder::Params
+ end
+
def filter_items(_items)
items = by_commit(super)
items = by_source_branch(items)
@@ -62,12 +68,14 @@ class MergeRequestsFinder < IssuableFinder
items = by_merged_at(items)
items = by_approvals(items)
items = by_deployments(items)
+ items = by_reviewer(items)
by_source_project_id(items)
end
def filter_negated_items(items)
items = super(items)
+ items = by_negated_reviewer(items)
by_negated_target_branch(items)
end
@@ -186,6 +194,30 @@ class MergeRequestsFinder < IssuableFinder
items.where_exists(deploys)
end
+
+ def by_reviewer(items)
+ return items unless params.reviewer_id? || params.reviewer_username?
+
+ if params.filter_by_no_reviewer?
+ items.no_review_requested
+ elsif params.filter_by_any_reviewer?
+ items.review_requested
+ elsif params.reviewer
+ items.review_requested_to(params.reviewer)
+ else # reviewer not found
+ items.none
+ end
+ end
+
+ def by_negated_reviewer(items)
+ return items unless not_params.reviewer_id? || not_params.reviewer_username?
+
+ if not_params.reviewer.present?
+ items.no_review_requested_to(not_params.reviewer)
+ else # reviewer not found
+ items.none
+ end
+ end
end
MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder')
diff --git a/app/finders/merge_requests_finder/params.rb b/app/finders/merge_requests_finder/params.rb
new file mode 100644
index 00000000000..e44e96054d3
--- /dev/null
+++ b/app/finders/merge_requests_finder/params.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class MergeRequestsFinder
+ class Params < IssuableFinder::Params
+ def filter_by_no_reviewer?
+ params[:reviewer_id].to_s.downcase == FILTER_NONE
+ end
+
+ def filter_by_any_reviewer?
+ params[:reviewer_id].to_s.downcase == FILTER_ANY
+ end
+
+ def reviewer
+ strong_memoize(:reviewer) do
+ if reviewer_id?
+ User.find_by_id(params[:reviewer_id])
+ elsif reviewer_username?
+ User.find_by_username(params[:reviewer_username])
+ else
+ nil
+ end
+ end
+ end
+ end
+end
diff --git a/app/finders/releases/evidence_pipeline_finder.rb b/app/finders/releases/evidence_pipeline_finder.rb
new file mode 100644
index 00000000000..2e706087feb
--- /dev/null
+++ b/app/finders/releases/evidence_pipeline_finder.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Releases
+ class EvidencePipelineFinder
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :project, :params
+
+ def initialize(project, params = {})
+ @project = project
+ @params = params
+ end
+
+ def execute
+ # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245
+ return params[:evidence_pipeline] if params[:evidence_pipeline]
+
+ sha = existing_tag&.dereferenced_target&.sha
+ sha ||= repository&.commit(ref)&.sha
+
+ return unless sha
+
+ project.ci_pipelines.for_sha(sha).last
+ end
+
+ private
+
+ def repository
+ strong_memoize(:repository) do
+ project.repository
+ end
+ end
+
+ def existing_tag
+ repository.find_tag(tag_name)
+ end
+
+ def tag_name
+ params[:tag]
+ end
+
+ def ref
+ params[:ref]
+ end
+ end
+end
diff --git a/app/graphql/mutations/alert_management/base.rb b/app/graphql/mutations/alert_management/base.rb
index 81d5ee95f06..8c6b4005cf8 100644
--- a/app/graphql/mutations/alert_management/base.rb
+++ b/app/graphql/mutations/alert_management/base.rb
@@ -11,7 +11,7 @@ module Mutations
argument :iid, GraphQL::STRING_TYPE,
required: true,
- description: "The iid of the alert to mutate"
+ description: "The IID of the alert to mutate"
field :alert,
Types::AlertManagement::AlertType,
diff --git a/app/graphql/mutations/alert_management/create_alert_issue.rb b/app/graphql/mutations/alert_management/create_alert_issue.rb
index 2ddb94700c2..2c128e1b339 100644
--- a/app/graphql/mutations/alert_management/create_alert_issue.rb
+++ b/app/graphql/mutations/alert_management/create_alert_issue.rb
@@ -10,6 +10,7 @@ module Mutations
result = create_alert_issue(alert, current_user)
track_usage_event(:incident_management_incident_created, current_user.id)
+ track_usage_event(:incident_management_alert_create_incident, current_user.id)
prepare_response(alert, result)
end
diff --git a/app/graphql/mutations/alert_management/http_integration/destroy.rb b/app/graphql/mutations/alert_management/http_integration/destroy.rb
index 0f478760aab..45d4bd778da 100644
--- a/app/graphql/mutations/alert_management/http_integration/destroy.rb
+++ b/app/graphql/mutations/alert_management/http_integration/destroy.rb
@@ -8,7 +8,7 @@ module Mutations
argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
required: true,
- description: "The id of the integration to remove"
+ description: "The ID of the integration to remove"
def resolve(id:)
integration = authorized_find!(id: id)
diff --git a/app/graphql/mutations/alert_management/http_integration/reset_token.rb b/app/graphql/mutations/alert_management/http_integration/reset_token.rb
index eefab156825..3938b38260e 100644
--- a/app/graphql/mutations/alert_management/http_integration/reset_token.rb
+++ b/app/graphql/mutations/alert_management/http_integration/reset_token.rb
@@ -8,7 +8,7 @@ module Mutations
argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
required: true,
- description: "The id of the integration to mutate"
+ description: "The ID of the integration to mutate"
def resolve(id:)
integration = authorized_find!(id: id)
diff --git a/app/graphql/mutations/alert_management/http_integration/update.rb b/app/graphql/mutations/alert_management/http_integration/update.rb
index 309c45b04ac..98e0f7eb14f 100644
--- a/app/graphql/mutations/alert_management/http_integration/update.rb
+++ b/app/graphql/mutations/alert_management/http_integration/update.rb
@@ -8,7 +8,7 @@ module Mutations
argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
required: true,
- description: "The id of the integration to mutate"
+ description: "The ID of the integration to mutate"
argument :name, GraphQL::STRING_TYPE,
required: false,
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
index 745ac51f6e3..effecd8364d 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/reset_token.rb
@@ -8,7 +8,7 @@ module Mutations
argument :id, Types::GlobalIDType[::PrometheusService],
required: true,
- description: "The id of the integration to mutate"
+ description: "The ID of the integration to mutate"
def resolve(id:)
integration = authorized_find!(id: id)
diff --git a/app/graphql/mutations/alert_management/prometheus_integration/update.rb b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
index 1f0dea119c5..46f4c23b739 100644
--- a/app/graphql/mutations/alert_management/prometheus_integration/update.rb
+++ b/app/graphql/mutations/alert_management/prometheus_integration/update.rb
@@ -8,7 +8,7 @@ module Mutations
argument :id, Types::GlobalIDType[::PrometheusService],
required: true,
- description: "The id of the integration to mutate"
+ description: "The ID of the integration to mutate"
argument :active, GraphQL::BOOLEAN_TYPE,
required: false,
diff --git a/app/graphql/mutations/award_emojis/add.rb b/app/graphql/mutations/award_emojis/add.rb
index 856fdd5fb14..e7ee2ec4fad 100644
--- a/app/graphql/mutations/award_emojis/add.rb
+++ b/app/graphql/mutations/award_emojis/add.rb
@@ -8,8 +8,6 @@ module Mutations
def resolve(args)
awardable = authorized_find!(id: args[:awardable_id])
- check_object_is_awardable!(awardable)
-
service = ::AwardEmojis::AddService.new(awardable, args[:name], current_user).execute
{
diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb
index df6b883529e..4bd8304c3fc 100644
--- a/app/graphql/mutations/award_emojis/base.rb
+++ b/app/graphql/mutations/award_emojis/base.rb
@@ -3,12 +3,16 @@
module Mutations
module AwardEmojis
class Base < BaseMutation
+ include ::Mutations::FindsByGid
+
+ NOT_EMOJI_AWARDABLE = 'You cannot award emoji to this resource.'
+
authorize :award_emoji
argument :awardable_id,
::Types::GlobalIDType[::Awardable],
required: true,
- description: 'The global id of the awardable resource'
+ description: 'The global ID of the awardable resource'
argument :name,
GraphQL::STRING_TYPE,
@@ -22,20 +26,15 @@ module Mutations
private
+ # TODO: remove this method when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
def find_object(id:)
- # TODO: remove this line when the compatibility layer is removed
- # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
- id = ::Types::GlobalIDType[::Awardable].coerce_isolated_input(id)
- GitlabSchema.find_by_gid(id)
+ super(id: ::Types::GlobalIDType[::Awardable].coerce_isolated_input(id))
end
- # Called by mutations methods after performing an authorization check
- # of an awardable object.
- def check_object_is_awardable!(object)
- unless object.is_a?(Awardable) && object.emoji_awardable?
- raise Gitlab::Graphql::Errors::ResourceNotAvailable,
- 'Cannot award emoji to this resource'
- end
+ def authorize!(object)
+ super
+ raise_resource_not_available_error!(NOT_EMOJI_AWARDABLE) unless object.emoji_awardable?
end
end
end
diff --git a/app/graphql/mutations/award_emojis/remove.rb b/app/graphql/mutations/award_emojis/remove.rb
index c654688c6dc..a9655daeea7 100644
--- a/app/graphql/mutations/award_emojis/remove.rb
+++ b/app/graphql/mutations/award_emojis/remove.rb
@@ -8,8 +8,6 @@ module Mutations
def resolve(args)
awardable = authorized_find!(id: args[:awardable_id])
- check_object_is_awardable!(awardable)
-
service = ::AwardEmojis::DestroyService.new(awardable, args[:name], current_user).execute
{
diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb
index 679ec7a14ff..e741f972b1b 100644
--- a/app/graphql/mutations/award_emojis/toggle.rb
+++ b/app/graphql/mutations/award_emojis/toggle.rb
@@ -12,8 +12,6 @@ module Mutations
def resolve(args)
awardable = authorized_find!(id: args[:awardable_id])
- check_object_is_awardable!(awardable)
-
service = ::AwardEmojis::ToggleService.new(awardable, args[:name], current_user).execute
toggled_on = awardable.awarded_emoji?(args[:name], current_user)
diff --git a/app/graphql/mutations/boards/common_mutation_arguments.rb b/app/graphql/mutations/boards/common_mutation_arguments.rb
new file mode 100644
index 00000000000..c4f8d299318
--- /dev/null
+++ b/app/graphql/mutations/boards/common_mutation_arguments.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Boards
+ module CommonMutationArguments
+ extend ActiveSupport::Concern
+
+ included do
+ argument :name,
+ GraphQL::STRING_TYPE,
+ required: false,
+ description: 'The board name.'
+ argument :hide_backlog_list,
+ GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: copy_field_description(Types::BoardType, :hide_backlog_list)
+ argument :hide_closed_list,
+ GraphQL::BOOLEAN_TYPE,
+ required: false,
+ description: copy_field_description(Types::BoardType, :hide_closed_list)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/boards/create.rb b/app/graphql/mutations/boards/create.rb
index ebbd19930ec..92bce557446 100644
--- a/app/graphql/mutations/boards/create.rb
+++ b/app/graphql/mutations/boards/create.rb
@@ -7,36 +7,18 @@ module Mutations
graphql_name 'CreateBoard'
+ include Mutations::Boards::CommonMutationArguments
+
field :board,
Types::BoardType,
null: true,
description: 'The board after mutation.'
- argument :name,
- GraphQL::STRING_TYPE,
- required: false,
- description: 'The board name.'
- argument :assignee_id,
- GraphQL::STRING_TYPE,
- required: false,
- description: 'The ID of the user to be assigned to the board.'
- argument :milestone_id,
- Types::GlobalIDType[Milestone],
- required: false,
- description: 'The ID of the milestone to be assigned to the board.'
- argument :weight,
- GraphQL::BOOLEAN_TYPE,
- required: false,
- description: 'The weight of the board.'
- argument :label_ids,
- [Types::GlobalIDType[Label]],
- required: false,
- description: 'The IDs of labels to be added to the board.'
-
authorize :admin_board
def resolve(args)
board_parent = authorized_resource_parent_find!(args)
+
response = ::Boards::CreateService.new(board_parent, current_user, args).execute
{
@@ -47,3 +29,5 @@ module Mutations
end
end
end
+
+Mutations::Boards::Create.prepend_if_ee('::EE::Mutations::Boards::Create')
diff --git a/app/graphql/mutations/boards/lists/create.rb b/app/graphql/mutations/boards/lists/create.rb
index 3fe1052315f..f6df63365b2 100644
--- a/app/graphql/mutations/boards/lists/create.rb
+++ b/app/graphql/mutations/boards/lists/create.rb
@@ -27,30 +27,16 @@ module Mutations
board = authorized_find!(id: args[:board_id])
params = create_list_params(args)
- authorize_list_type_resource!(board, params)
-
- list = create_list(board, params)
+ response = create_list(board, params)
{
- list: list.valid? ? list : nil,
- errors: errors_on_object(list)
+ list: response.success? ? response.payload[:list] : nil,
+ errors: response.errors
}
end
private
- # Overridden in EE
- def authorize_list_type_resource!(board, params)
- return unless params[:label_id]
-
- labels = ::Labels::AvailableLabelsService.new(current_user, board.resource_parent, params)
- .filter_labels_ids_in_param(:label_id)
-
- unless labels.present?
- raise Gitlab::Graphql::Errors::ArgumentError, 'Label not found!'
- end
- end
-
def create_list(board, params)
create_list_service =
::Boards::Lists::CreateService.new(board.resource_parent, current_user, params)
diff --git a/app/graphql/mutations/boards/update.rb b/app/graphql/mutations/boards/update.rb
new file mode 100644
index 00000000000..5cb434e41fd
--- /dev/null
+++ b/app/graphql/mutations/boards/update.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Boards
+ class Update < ::Mutations::BaseMutation
+ graphql_name 'UpdateBoard'
+
+ include Mutations::Boards::CommonMutationArguments
+
+ argument :id,
+ ::Types::GlobalIDType[::Board],
+ required: true,
+ description: 'The board global ID.'
+
+ field :board,
+ Types::BoardType,
+ null: true,
+ description: 'The board after mutation.'
+
+ authorize :admin_board
+
+ def resolve(id:, **args)
+ board = authorized_find!(id: id)
+
+ ::Boards::UpdateService.new(board.resource_parent, current_user, args).execute(board)
+
+ {
+ board: board,
+ errors: errors_on_object(board)
+ }
+ 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 = ::Types::GlobalIDType[::Board].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
+
+Mutations::Boards::Update.prepend_if_ee('::EE::Mutations::Boards::Update')
diff --git a/app/graphql/mutations/ci/base.rb b/app/graphql/mutations/ci/base.rb
index aaece2a3021..0ccee5661b7 100644
--- a/app/graphql/mutations/ci/base.rb
+++ b/app/graphql/mutations/ci/base.rb
@@ -7,7 +7,7 @@ module Mutations
argument :id, PipelineID,
required: true,
- description: 'The id of the pipeline to mutate'
+ description: 'The ID of the pipeline to mutate'
private
diff --git a/app/graphql/mutations/concerns/mutations/finds_by_gid.rb b/app/graphql/mutations/concerns/mutations/finds_by_gid.rb
new file mode 100644
index 00000000000..157f87a413d
--- /dev/null
+++ b/app/graphql/mutations/concerns/mutations/finds_by_gid.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Mutations
+ module FindsByGid
+ def find_object(id:)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+end
diff --git a/app/graphql/mutations/container_repositories/destroy.rb b/app/graphql/mutations/container_repositories/destroy.rb
index 8312193147f..90fba66e7b3 100644
--- a/app/graphql/mutations/container_repositories/destroy.rb
+++ b/app/graphql/mutations/container_repositories/destroy.rb
@@ -2,9 +2,7 @@
module Mutations
module ContainerRepositories
- class Destroy < Mutations::BaseMutation
- include ::Mutations::PackageEventable
-
+ class Destroy < ::Mutations::ContainerRepositories::DestroyBase
graphql_name 'DestroyContainerRepository'
authorize :destroy_container_image
@@ -31,15 +29,6 @@ module Mutations
errors: []
}
end
-
- private
-
- def find_object(id:)
- # TODO: remove this line when the compatibility layer is removed
- # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
- id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id)
- GitlabSchema.find_by_gid(id)
- end
end
end
end
diff --git a/app/graphql/mutations/container_repositories/destroy_base.rb b/app/graphql/mutations/container_repositories/destroy_base.rb
new file mode 100644
index 00000000000..ddaa6c52121
--- /dev/null
+++ b/app/graphql/mutations/container_repositories/destroy_base.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ContainerRepositories
+ class DestroyBase < Mutations::BaseMutation
+ include ::Mutations::PackageEventable
+
+ private
+
+ def find_object(id:)
+ # TODO: remove this line when the compatibility layer is removed
+ # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/container_repositories/destroy_tags.rb b/app/graphql/mutations/container_repositories/destroy_tags.rb
new file mode 100644
index 00000000000..ca6a67867c3
--- /dev/null
+++ b/app/graphql/mutations/container_repositories/destroy_tags.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module Mutations
+ module ContainerRepositories
+ class DestroyTags < ::Mutations::ContainerRepositories::DestroyBase
+ LIMIT = 20.freeze
+
+ TOO_MANY_TAGS_ERROR_MESSAGE = "Number of tags is greater than #{LIMIT}"
+
+ graphql_name 'DestroyContainerRepositoryTags'
+
+ authorize :destroy_container_image
+
+ argument :id,
+ ::Types::GlobalIDType[::ContainerRepository],
+ required: true,
+ description: 'ID of the container repository.'
+
+ argument :tag_names,
+ [GraphQL::STRING_TYPE],
+ required: true,
+ description: "Container repository tag(s) to delete. Total number can't be greater than #{LIMIT}",
+ prepare: ->(tag_names, _) do
+ raise Gitlab::Graphql::Errors::ArgumentError, TOO_MANY_TAGS_ERROR_MESSAGE if tag_names.size > LIMIT
+
+ tag_names
+ end
+
+ field :deleted_tag_names,
+ [GraphQL::STRING_TYPE],
+ description: 'Deleted container repository tags',
+ null: false
+
+ def resolve(id:, tag_names:)
+ container_repository = authorized_find!(id: id)
+
+ result = ::Projects::ContainerRepository::DeleteTagsService
+ .new(container_repository.project, current_user, tags: tag_names)
+ .execute(container_repository)
+
+ track_event(:delete_tag_bulk, :tag) if result[:status] == :success
+
+ {
+ errors: Array(result[:message]),
+ deleted_tag_names: result[:deleted] || []
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/design_management/base.rb b/app/graphql/mutations/design_management/base.rb
index 918e5709b94..69fd22e46cd 100644
--- a/app/graphql/mutations/design_management/base.rb
+++ b/app/graphql/mutations/design_management/base.rb
@@ -11,7 +11,7 @@ module Mutations
argument :iid, GraphQL::ID_TYPE,
required: true,
- description: "The iid of the issue to modify designs for"
+ description: "The IID of the issue to modify designs for"
private
diff --git a/app/graphql/mutations/discussions/toggle_resolve.rb b/app/graphql/mutations/discussions/toggle_resolve.rb
index 4492da74706..0e3baf8d548 100644
--- a/app/graphql/mutations/discussions/toggle_resolve.rb
+++ b/app/graphql/mutations/discussions/toggle_resolve.rb
@@ -10,7 +10,7 @@ module Mutations
argument :id,
Types::GlobalIDType[Discussion],
required: true,
- description: 'The global id of the discussion'
+ description: 'The global ID of the discussion'
argument :resolve,
GraphQL::BOOLEAN_TYPE,
diff --git a/app/graphql/mutations/environments/canary_ingress/update.rb b/app/graphql/mutations/environments/canary_ingress/update.rb
new file mode 100644
index 00000000000..1798143053a
--- /dev/null
+++ b/app/graphql/mutations/environments/canary_ingress/update.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Environments
+ module CanaryIngress
+ class Update < ::Mutations::BaseMutation
+ graphql_name 'EnvironmentsCanaryIngressUpdate'
+
+ authorize :update_environment
+
+ argument :id,
+ ::Types::GlobalIDType[::Environment],
+ required: true,
+ description: 'The global ID of the environment to update'
+
+ argument :weight,
+ GraphQL::INT_TYPE,
+ required: true,
+ description: 'The weight of the Canary Ingress'
+
+ def resolve(id:, **kwargs)
+ environment = authorized_find!(id: id)
+
+ result = ::Environments::CanaryIngress::UpdateService
+ .new(environment.project, current_user, kwargs)
+ .execute_async(environment)
+
+ { errors: Array.wrap(result[:message]) }
+ end
+
+ def find_object(id:)
+ # TODO: remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/257883
+ id = ::Types::GlobalIDType[::Environment].coerce_isolated_input(id)
+ GitlabSchema.find_by_gid(id)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb
index 9b216b31f9b..d34e351b2a6 100644
--- a/app/graphql/mutations/issues/update.rb
+++ b/app/graphql/mutations/issues/update.rb
@@ -11,7 +11,7 @@ module Mutations
required: false,
description: copy_field_description(Types::IssueType, :title)
- argument :milestone_id, GraphQL::ID_TYPE,
+ argument :milestone_id, GraphQL::ID_TYPE, # rubocop: disable Graphql/IDType
required: false,
description: 'The ID of the milestone to assign to the issue. On update milestone will be removed if set to null'
diff --git a/app/graphql/mutations/merge_requests/base.rb b/app/graphql/mutations/merge_requests/base.rb
index 96228855ace..57920259cf7 100644
--- a/app/graphql/mutations/merge_requests/base.rb
+++ b/app/graphql/mutations/merge_requests/base.rb
@@ -11,7 +11,7 @@ module Mutations
argument :iid, GraphQL::STRING_TYPE,
required: true,
- description: "The iid of the merge request to mutate"
+ description: "The IID of the merge request to mutate"
field :merge_request,
Types::MergeRequestType,
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/create.rb b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
index b064f55825f..c2ec88c68ed 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/create.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/create.rb
@@ -20,12 +20,12 @@ module Mutations
argument :environment_id,
::Types::GlobalIDType[::Environment],
required: false,
- description: 'The global id of the environment to add an annotation to'
+ description: 'The global ID of the environment to add an annotation to'
argument :cluster_id,
::Types::GlobalIDType[::Clusters::Cluster],
required: false,
- description: 'The global id of the cluster to add an annotation to'
+ description: 'The global ID of the cluster to add an annotation to'
argument :starting_at, Types::TimeType,
required: true,
diff --git a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
index d6731dfcafd..5d6763d8711 100644
--- a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
+++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb
@@ -11,7 +11,7 @@ module Mutations
argument :id, ::Types::GlobalIDType[::Metrics::Dashboard::Annotation],
required: true,
- description: 'The global ID of the annotation to delete'
+ description: 'Global ID of the annotation to delete'
def resolve(id:)
annotation = authorized_find!(id: id)
diff --git a/app/graphql/mutations/notes/create/base.rb b/app/graphql/mutations/notes/create/base.rb
index 3cfdaf84760..a1d81c62d91 100644
--- a/app/graphql/mutations/notes/create/base.rb
+++ b/app/graphql/mutations/notes/create/base.rb
@@ -11,7 +11,7 @@ module Mutations
argument :noteable_id,
::Types::GlobalIDType[::Noteable],
required: true,
- description: 'The global id of the resource to add a note to'
+ description: 'The global ID of the resource to add a note to'
argument :body,
GraphQL::STRING_TYPE,
diff --git a/app/graphql/mutations/notes/create/note.rb b/app/graphql/mutations/notes/create/note.rb
index e97037171f7..f1cd3bddca8 100644
--- a/app/graphql/mutations/notes/create/note.rb
+++ b/app/graphql/mutations/notes/create/note.rb
@@ -9,7 +9,7 @@ module Mutations
argument :discussion_id,
::Types::GlobalIDType[::Discussion],
required: false,
- description: 'The global id of the discussion this note is in reply to'
+ description: 'The global ID of the discussion this note is in reply to'
private
diff --git a/app/graphql/mutations/notes/destroy.rb b/app/graphql/mutations/notes/destroy.rb
index 63e5eeb5ecf..0e6a215bf00 100644
--- a/app/graphql/mutations/notes/destroy.rb
+++ b/app/graphql/mutations/notes/destroy.rb
@@ -10,7 +10,7 @@ module Mutations
argument :id,
::Types::GlobalIDType[::Note],
required: true,
- description: 'The global id of the note to destroy'
+ description: 'The global ID of the note to destroy'
def resolve(id:)
note = authorized_find!(id: id)
diff --git a/app/graphql/mutations/notes/reposition_image_diff_note.rb b/app/graphql/mutations/notes/reposition_image_diff_note.rb
index 0d88bcd9a30..15bfb361b13 100644
--- a/app/graphql/mutations/notes/reposition_image_diff_note.rb
+++ b/app/graphql/mutations/notes/reposition_image_diff_note.rb
@@ -16,7 +16,7 @@ module Mutations
loads: Types::Notes::NoteType,
as: :note,
required: true,
- description: 'The global id of the DiffNote to update'
+ description: 'The global ID of the DiffNote to update'
argument :position,
Types::Notes::UpdateDiffImagePositionInputType,
diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb
index 1d5738ada77..42dac20f5d3 100644
--- a/app/graphql/mutations/notes/update/base.rb
+++ b/app/graphql/mutations/notes/update/base.rb
@@ -11,7 +11,7 @@ module Mutations
argument :id,
::Types::GlobalIDType[::Note],
required: true,
- description: 'The global id of the note to update'
+ description: 'The global ID of the note to update'
def resolve(args)
note = authorized_find!(id: args[:id])
diff --git a/app/graphql/mutations/releases/create.rb b/app/graphql/mutations/releases/create.rb
index 57c1541c368..156cd252848 100644
--- a/app/graphql/mutations/releases/create.rb
+++ b/app/graphql/mutations/releases/create.rb
@@ -40,12 +40,11 @@ module Mutations
authorize :create_release
- def resolve(project_path:, milestones: nil, assets: nil, **scalars)
+ def resolve(project_path:, assets: nil, **scalars)
project = authorized_find!(full_path: project_path)
params = {
**scalars,
- milestones: milestones.presence || [],
assets: assets.to_h
}.with_indifferent_access
diff --git a/app/graphql/mutations/releases/delete.rb b/app/graphql/mutations/releases/delete.rb
new file mode 100644
index 00000000000..e887b702cce
--- /dev/null
+++ b/app/graphql/mutations/releases/delete.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Releases
+ class Delete < Base
+ graphql_name 'ReleaseDelete'
+
+ field :release,
+ Types::ReleaseType,
+ null: true,
+ description: 'The deleted release.'
+
+ argument :tag_name, GraphQL::STRING_TYPE,
+ required: true, as: :tag,
+ description: 'Name of the tag associated with the release to delete.'
+
+ authorize :destroy_release
+
+ def resolve(project_path:, tag:)
+ project = authorized_find!(full_path: project_path)
+
+ params = { tag: tag }.with_indifferent_access
+
+ result = ::Releases::DestroyService.new(project, current_user, params).execute
+
+ if result[:status] == :success
+ {
+ release: result[:release],
+ errors: []
+ }
+ else
+ {
+ release: nil,
+ errors: [result[:message]]
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/releases/update.rb b/app/graphql/mutations/releases/update.rb
new file mode 100644
index 00000000000..bf72b907679
--- /dev/null
+++ b/app/graphql/mutations/releases/update.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Releases
+ class Update < Base
+ graphql_name 'ReleaseUpdate'
+
+ field :release,
+ Types::ReleaseType,
+ null: true,
+ description: 'The release after mutation.'
+
+ argument :tag_name, GraphQL::STRING_TYPE,
+ required: true, as: :tag,
+ description: 'Name of the tag associated with the release'
+
+ argument :name, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Name of the release'
+
+ argument :description, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Description (release notes) of the release'
+
+ argument :released_at, Types::TimeType,
+ required: false,
+ description: 'The release date'
+
+ argument :milestones, [GraphQL::STRING_TYPE],
+ required: false,
+ description: 'The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.'
+
+ authorize :update_release
+
+ def ready?(**args)
+ if args.key?(:released_at) && args[:released_at].nil?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'if the releasedAt argument is provided, it cannot be null'
+ end
+
+ if args.key?(:milestones) && args[:milestones].nil?
+ raise Gitlab::Graphql::Errors::ArgumentError,
+ 'if the milestones argument is provided, it cannot be null'
+ end
+
+ super
+ end
+
+ def resolve(project_path:, **scalars)
+ project = authorized_find!(full_path: project_path)
+
+ params = scalars.with_indifferent_access
+
+ release_result = ::Releases::UpdateService.new(project, current_user, params).execute
+
+ if release_result[:status] == :success
+ {
+ release: release_result[:release],
+ errors: []
+ }
+ else
+ {
+ release: nil,
+ errors: [release_result[:message]]
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 37c0f80310c..56c3b398949 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -4,7 +4,8 @@ module Mutations
module Snippets
class Create < BaseMutation
include SpammableMutationFields
- include ResolvesProject
+
+ authorize :create_snippet
graphql_name 'CreateSnippet'
@@ -37,17 +38,15 @@ module Mutations
description: 'Actions to perform over the snippet repository and blobs',
required: false
- def resolve(args)
- project_path = args.delete(:project_path)
-
+ def resolve(project_path: nil, **args)
if project_path.present?
- project = find_project!(project_path: project_path)
- elsif !can_create_personal_snippet?
- raise_resource_not_available_error!
+ project = authorized_find!(project_path)
+ else
+ authorize!(:global)
end
service_response = ::Snippets::CreateService.new(project,
- context[:current_user],
+ current_user,
create_params(args)).execute
snippet = service_response.payload[:snippet]
@@ -67,20 +66,8 @@ module Mutations
private
- def find_project!(project_path:)
- authorized_find!(full_path: project_path)
- end
-
- def find_object(full_path:)
- resolve_project(full_path: full_path)
- end
-
- def authorized_resource?(project)
- Ability.allowed?(context[:current_user], :create_snippet, project)
- end
-
- def can_create_personal_snippet?
- Ability.allowed?(context[:current_user], :create_snippet)
+ def find_object(full_path)
+ Project.find_by_full_path(full_path)
end
def create_params(args)
diff --git a/app/graphql/mutations/snippets/destroy.rb b/app/graphql/mutations/snippets/destroy.rb
index 4915d7dd77a..bee6503372d 100644
--- a/app/graphql/mutations/snippets/destroy.rb
+++ b/app/graphql/mutations/snippets/destroy.rb
@@ -9,7 +9,7 @@ module Mutations
argument :id, ::Types::GlobalIDType[::Snippet],
required: true,
- description: 'The global id of the snippet to destroy'
+ description: 'The global ID of the snippet to destroy'
def resolve(id:)
snippet = authorized_find!(id: id)
diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb
index d6b96c699c0..2d6fea1f5ec 100644
--- a/app/graphql/mutations/snippets/mark_as_spam.rb
+++ b/app/graphql/mutations/snippets/mark_as_spam.rb
@@ -7,7 +7,7 @@ module Mutations
argument :id, ::Types::GlobalIDType[::Snippet],
required: true,
- description: 'The global id of the snippet to update'
+ description: 'The global ID of the snippet to update'
def resolve(id:)
snippet = authorized_find!(id: id)
@@ -23,7 +23,7 @@ module Mutations
private
def mark_as_spam(snippet)
- Spam::MarkAsSpamService.new(spammable: snippet).execute
+ Spam::MarkAsSpamService.new(target: snippet).execute
end
def authorized_resource?(snippet)
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index bcaa807e4c1..6df1ad6d8b9 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -9,7 +9,7 @@ module Mutations
argument :id, ::Types::GlobalIDType[::Snippet],
required: true,
- description: 'The global id of the snippet to update'
+ description: 'The global ID of the snippet to update'
argument :title, GraphQL::STRING_TYPE,
required: false,
@@ -27,11 +27,11 @@ module Mutations
description: 'Actions to perform over the snippet repository and blobs',
required: false
- def resolve(args)
- snippet = authorized_find!(id: args.delete(:id))
+ def resolve(id:, **args)
+ snippet = authorized_find!(id: id)
result = ::Snippets::UpdateService.new(snippet.project,
- context[:current_user],
+ current_user,
update_params(args)).execute(snippet)
snippet = result.payload[:snippet]
diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb
index 3d73022f266..2ae50846108 100644
--- a/app/graphql/mutations/todos/mark_done.rb
+++ b/app/graphql/mutations/todos/mark_done.rb
@@ -10,7 +10,7 @@ module Mutations
argument :id,
::Types::GlobalIDType[::Todo],
required: true,
- description: 'The global id of the todo to mark as done'
+ description: 'The global ID of the todo to mark as done'
field :todo, Types::TodoType,
null: false,
diff --git a/app/graphql/mutations/todos/restore.rb b/app/graphql/mutations/todos/restore.rb
index 7c8f92d32f5..c532b455a16 100644
--- a/app/graphql/mutations/todos/restore.rb
+++ b/app/graphql/mutations/todos/restore.rb
@@ -10,7 +10,7 @@ module Mutations
argument :id,
::Types::GlobalIDType[::Todo],
required: true,
- description: 'The global id of the todo to restore'
+ description: 'The global ID of the todo to restore'
field :todo, Types::TodoType,
null: false,
diff --git a/app/graphql/mutations/todos/restore_many.rb b/app/graphql/mutations/todos/restore_many.rb
index 9e0a95c48ec..59965589856 100644
--- a/app/graphql/mutations/todos/restore_many.rb
+++ b/app/graphql/mutations/todos/restore_many.rb
@@ -10,11 +10,11 @@ module Mutations
argument :ids,
[::Types::GlobalIDType[::Todo]],
required: true,
- description: 'The global ids of the todos to restore (a maximum of 50 is supported at once)'
+ description: 'The global IDs of the todos to restore (a maximum of 50 is supported at once)'
field :updated_ids, [::Types::GlobalIDType[Todo]],
null: false,
- description: 'The ids of the updated todo items',
+ description: 'The IDs of the updated todo items',
deprecated: { reason: 'Use todos', milestone: '13.2' }
field :todos, [::Types::TodoType],
diff --git a/app/graphql/queries/epic/epic_children.query.graphql b/app/graphql/queries/epic/epic_children.query.graphql
new file mode 100644
index 00000000000..c12778109d0
--- /dev/null
+++ b/app/graphql/queries/epic/epic_children.query.graphql
@@ -0,0 +1,126 @@
+fragment PageInfo on PageInfo {
+ hasNextPage
+ hasPreviousPage
+ startCursor
+ endCursor
+}
+
+fragment RelatedTreeBaseEpic on Epic {
+ id
+ iid
+ title
+ webPath
+ relativePosition
+ userPermissions {
+ __typename
+ adminEpic
+ createEpic
+ }
+ descendantCounts {
+ __typename
+ openedEpics
+ closedEpics
+ openedIssues
+ closedIssues
+ }
+ healthStatus {
+ __typename
+ issuesAtRisk
+ issuesOnTrack
+ issuesNeedingAttention
+ }
+}
+
+fragment EpicNode on Epic {
+ ...RelatedTreeBaseEpic
+ state
+ reference(full: true)
+ relationPath
+ createdAt
+ closedAt
+ hasChildren
+ hasIssues
+ group {
+ __typename
+ fullPath
+ }
+}
+
+query childItems(
+ $fullPath: ID!
+ $iid: ID
+ $pageSize: Int = 100
+ $epicEndCursor: String = ""
+ $issueEndCursor: String = ""
+) {
+ group(fullPath: $fullPath) {
+ __typename
+ id
+ path
+ fullPath
+ epic(iid: $iid) {
+ __typename
+ ...RelatedTreeBaseEpic
+ children(first: $pageSize, after: $epicEndCursor) {
+ __typename
+ edges {
+ __typename
+ node {
+ __typename
+ ...EpicNode
+ }
+ }
+ pageInfo {
+ __typename
+ ...PageInfo
+ }
+ }
+ issues(first: $pageSize, after: $issueEndCursor) {
+ __typename
+ edges {
+ __typename
+ node {
+ __typename
+ iid
+ epicIssueId
+ title
+ closedAt
+ state
+ createdAt
+ confidential
+ dueDate
+ weight
+ webPath
+ reference(full: true)
+ relationPath
+ relativePosition
+ assignees {
+ __typename
+ edges {
+ __typename
+ node {
+ __typename
+ webUrl
+ name
+ username
+ avatarUrl
+ }
+ }
+ }
+ milestone {
+ __typename
+ title
+ startDate
+ dueDate
+ }
+ healthStatus
+ }
+ }
+ pageInfo {
+ __typename
+ ...PageInfo
+ }
+ }
+ }
+ }
+}
diff --git a/app/graphql/queries/epic/epic_details.query.graphql b/app/graphql/queries/epic/epic_details.query.graphql
new file mode 100644
index 00000000000..406d630b180
--- /dev/null
+++ b/app/graphql/queries/epic/epic_details.query.graphql
@@ -0,0 +1,20 @@
+query epicDetails($fullPath: ID!, $iid: ID!) {
+ group(fullPath: $fullPath) {
+ __typename
+ epic(iid: $iid) {
+ __typename
+ participants {
+ __typename
+ edges {
+ __typename
+ node {
+ __typename
+ name
+ avatarUrl
+ webUrl
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/graphql/resolvers/alert_management/alert_resolver.rb b/app/graphql/resolvers/alert_management/alert_resolver.rb
index c3219d9cdc3..b115bd80113 100644
--- a/app/graphql/resolvers/alert_management/alert_resolver.rb
+++ b/app/graphql/resolvers/alert_management/alert_resolver.rb
@@ -18,6 +18,11 @@ module Resolvers
description: 'Sort alerts by this criteria',
required: false
+ argument :domain, Types::AlertManagement::DomainFilterEnum,
+ description: 'Filter query for given domain',
+ required: true,
+ default_value: 'operations'
+
argument :search, GraphQL::STRING_TYPE,
description: 'Search query for title, description, service, or monitoring_tool.',
required: false
diff --git a/app/graphql/resolvers/assigned_merge_requests_resolver.rb b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
index 30415ef5d2d..385f8db51b0 100644
--- a/app/graphql/resolvers/assigned_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/assigned_merge_requests_resolver.rb
@@ -4,6 +4,7 @@ module Resolvers
class AssignedMergeRequestsResolver < UserMergeRequestsResolverBase
type ::Types::MergeRequestType.connection_type, null: true
accept_author
+ accept_reviewer
def user_role
:assignee
diff --git a/app/graphql/resolvers/authored_merge_requests_resolver.rb b/app/graphql/resolvers/authored_merge_requests_resolver.rb
index 1426ca83c06..4de1046ce0d 100644
--- a/app/graphql/resolvers/authored_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/authored_merge_requests_resolver.rb
@@ -4,6 +4,7 @@ module Resolvers
class AuthoredMergeRequestsResolver < UserMergeRequestsResolverBase
type ::Types::MergeRequestType.connection_type, null: true
accept_assignee
+ accept_reviewer
def user_role
:author
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 87a63231b22..539e37db1c2 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -8,6 +8,14 @@ module Resolvers
argument_class ::Types::BaseArgument
+ def self.requires_argument!
+ @requires_argument = true
+ end
+
+ def self.field_options
+ super.merge(requires_argument: @requires_argument)
+ end
+
def self.singular_type
return unless type
@@ -109,6 +117,10 @@ module Resolvers
[args[:iid], args[:iids]].any? ? 0 : 0.01
end
+ def offset_pagination(relation)
+ ::Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(relation)
+ end
+
override :object
def object
super.tap do |obj|
diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb
index 3421e1024c0..3e4a5a3cb70 100644
--- a/app/graphql/resolvers/board_list_issues_resolver.rb
+++ b/app/graphql/resolvers/board_list_issues_resolver.rb
@@ -16,7 +16,7 @@ module Resolvers
filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
- Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute)
+ offset_pagination(service.execute)
end
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681
diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb
index ef12dfa19ff..35d938c50be 100644
--- a/app/graphql/resolvers/board_lists_resolver.rb
+++ b/app/graphql/resolvers/board_lists_resolver.rb
@@ -3,9 +3,13 @@
module Resolvers
class BoardListsResolver < BaseResolver
include BoardIssueFilterable
+ prepend ManualAuthorization
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::BoardListType, null: true
+ extras [:lookahead]
+
+ authorize :read_list
argument :id, Types::GlobalIDType[List],
required: false,
@@ -27,7 +31,7 @@ module Resolvers
List.preload_preferences_for_user(lists, context[:current_user])
end
- Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(lists)
+ offset_pagination(lists)
end
private
@@ -42,10 +46,6 @@ module Resolvers
service.execute(board, create_default_lists: false)
end
- def authorized_resource?(board)
- Ability.allowed?(context[:current_user], :read_list, board)
- end
-
def load_preferences?(lookahead)
lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed)
end
diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb
new file mode 100644
index 00000000000..d6e7c206691
--- /dev/null
+++ b/app/graphql/resolvers/ci/config_resolver.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class ConfigResolver < BaseResolver
+ type Types::Ci::Config::ConfigType, null: true
+
+ argument :content, GraphQL::STRING_TYPE,
+ required: true,
+ description: 'Contents of .gitlab-ci.yml'
+
+ def resolve(content:)
+ result = ::Gitlab::Ci::YamlProcessor.new(content).execute
+
+ response = if result.errors.empty?
+ {
+ status: :valid,
+ errors: [],
+ stages: make_stages(result.jobs)
+ }
+ else
+ {
+ status: :invalid,
+ errors: result.errors
+ }
+ end
+
+ response.merge(merged_yaml: result.merged_yaml)
+ end
+
+ private
+
+ def make_jobs(config_jobs)
+ config_jobs.map do |job_name, job|
+ {
+ name: job_name,
+ stage: job[:stage],
+ group_name: CommitStatus.new(name: job_name).group_name,
+ needs: job.dig(:needs, :job) || []
+ }
+ end
+ end
+
+ def make_groups(job_data)
+ jobs = make_jobs(job_data)
+
+ jobs_by_group = jobs.group_by { |job| job[:group_name] }
+ jobs_by_group.map do |name, jobs|
+ { jobs: jobs, name: name, stage: jobs.first[:stage], size: jobs.size }
+ end
+ end
+
+ def make_stages(jobs)
+ make_groups(jobs)
+ .group_by { |group| group[:stage] }
+ .map { |name, groups| { name: name, groups: groups } }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/jobs_resolver.rb b/app/graphql/resolvers/ci/jobs_resolver.rb
index 8a9ae42b375..2c4911748a5 100644
--- a/app/graphql/resolvers/ci/jobs_resolver.rb
+++ b/app/graphql/resolvers/ci/jobs_resolver.rb
@@ -5,6 +5,8 @@ module Resolvers
class JobsResolver < BaseResolver
alias_method :pipeline, :object
+ type ::Types::Ci::JobType.connection_type, null: true
+
argument :security_report_types, [Types::Security::ReportTypeEnum],
required: false,
description: 'Filter jobs by the type of security report they produce'
diff --git a/app/graphql/resolvers/ci/pipeline_stages_resolver.rb b/app/graphql/resolvers/ci/pipeline_stages_resolver.rb
index f9817d8b97b..98170e0cd2e 100644
--- a/app/graphql/resolvers/ci/pipeline_stages_resolver.rb
+++ b/app/graphql/resolvers/ci/pipeline_stages_resolver.rb
@@ -5,6 +5,9 @@ module Resolvers
class PipelineStagesResolver < BaseResolver
include LooksAhead
+ type Types::Ci::StageType.connection_type, null: true
+ extras [:lookahead]
+
alias_method :pipeline, :object
def resolve_with_lookahead
diff --git a/app/graphql/resolvers/ci/runner_setup_resolver.rb b/app/graphql/resolvers/ci/runner_setup_resolver.rb
index 241cd57f74b..f68d71174c3 100644
--- a/app/graphql/resolvers/ci/runner_setup_resolver.rb
+++ b/app/graphql/resolvers/ci/runner_setup_resolver.rb
@@ -23,7 +23,10 @@ module Resolvers
def resolve(platform:, architecture:, **args)
instructions = Gitlab::Ci::RunnerInstructions.new(
- { current_user: current_user, os: platform, arch: architecture }.merge(target_param(args))
+ current_user: current_user,
+ os: platform,
+ arch: architecture,
+ **target_param(args)
)
{
diff --git a/app/graphql/resolvers/concerns/caching_array_resolver.rb b/app/graphql/resolvers/concerns/caching_array_resolver.rb
index 4f2c8b98928..e7555dcf42c 100644
--- a/app/graphql/resolvers/concerns/caching_array_resolver.rb
+++ b/app/graphql/resolvers/concerns/caching_array_resolver.rb
@@ -43,8 +43,10 @@
# (i.e. `resolve(**args).sync == query_for(query_input(**args)).to_a`).
#
# Classes may implement:
-# - #item_found(A, R) (return value is ignored)
# - max_union_size Integer (the maximum number of queries to run in any one union)
+# - preload -> Preloads|NilClass (a set of preloads to apply to each query)
+# - #item_found(A, R) (return value is ignored)
+# - allowed?(R) -> Boolean (if this method returns false, the value is not resolved)
module CachingArrayResolver
MAX_UNION_SIZE = 50
@@ -62,6 +64,7 @@ module CachingArrayResolver
queries.in_groups_of(max_union_size, false).each do |group|
by_id = model_class
.from_union(tag(group), remove_duplicates: false)
+ .preload(preload) # rubocop: disable CodeReuse/ActiveRecord
.group_by { |r| r[primary_key] }
by_id.values.each do |item_group|
@@ -75,6 +78,16 @@ module CachingArrayResolver
end
end
+ # Override to apply filters on a per-item basis
+ def allowed?(item)
+ true
+ end
+
+ # Override to specify preloads for each query
+ def preload
+ nil
+ end
+
# Override this to intercept the items once they are found
def item_found(query_input, item)
end
@@ -94,6 +107,8 @@ module CachingArrayResolver
end
def found(loader, key, value)
+ return unless allowed?(value)
+
loader.call(key) do |vs|
item_found(key, value)
vs << value
diff --git a/app/graphql/resolvers/concerns/manual_authorization.rb b/app/graphql/resolvers/concerns/manual_authorization.rb
new file mode 100644
index 00000000000..182110b9594
--- /dev/null
+++ b/app/graphql/resolvers/concerns/manual_authorization.rb
@@ -0,0 +1,11 @@
+# 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/design_management/design_resolver.rb b/app/graphql/resolvers/design_management/design_resolver.rb
index e0a68bae397..b60c14ca835 100644
--- a/app/graphql/resolvers/design_management/design_resolver.rb
+++ b/app/graphql/resolvers/design_management/design_resolver.rb
@@ -5,6 +5,8 @@ module Resolvers
class DesignResolver < BaseResolver
type ::Types::DesignManagement::DesignType, null: true
+ requires_argument!
+
argument :id, ::Types::GlobalIDType[::DesignManagement::Design],
required: false,
description: 'Find a design by its ID'
diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
index 70021057f71..49a4974bfbf 100644
--- a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
+++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb
@@ -12,6 +12,8 @@ module Resolvers
type Types::DesignManagement::DesignAtVersionType, null: true
+ requires_argument!
+
authorize :read_design
argument :id, DesignAtVersionID,
diff --git a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
index ecd7ab3ee45..7d20cfc2c8e 100644
--- a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
+++ b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb
@@ -7,6 +7,8 @@ module Resolvers
type Types::DesignManagement::VersionType, null: true
+ requires_argument!
+
authorize :read_design
alias_method :collection, :object
diff --git a/app/graphql/resolvers/design_management/versions_resolver.rb b/app/graphql/resolvers/design_management/versions_resolver.rb
index 23858c8e991..3c718a631db 100644
--- a/app/graphql/resolvers/design_management/versions_resolver.rb
+++ b/app/graphql/resolvers/design_management/versions_resolver.rb
@@ -9,6 +9,8 @@ module Resolvers
VersionID = ::Types::GlobalIDType[::DesignManagement::Version]
+ extras [:parent]
+
argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE,
as: :sha,
required: false,
diff --git a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
index 669b487db10..13b5672d750 100644
--- a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
@@ -3,6 +3,8 @@
module Resolvers
module ErrorTracking
class SentryErrorStackTraceResolver < BaseResolver
+ type Types::ErrorTracking::SentryErrorStackTraceType, null: true
+
argument :id, ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError],
required: true,
description: 'ID of the Sentry issue'
diff --git a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb
index c5cf924ce7f..e844ffedbeb 100644
--- a/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_errors_resolver.rb
@@ -4,19 +4,26 @@ module Resolvers
module ErrorTracking
class SentryErrorsResolver < BaseResolver
type Types::ErrorTracking::SentryErrorType.connection_type, null: true
+ extension Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension
+
+ argument :search_term, ::GraphQL::STRING_TYPE,
+ description: 'Search query for the Sentry error details',
+ required: false
+
+ # TODO: convert to Enum
+ argument :sort, ::GraphQL::STRING_TYPE,
+ description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default',
+ required: false
+
+ delegate :project, to: :object
def resolve(**args)
args[:cursor] = args.delete(:after)
- project = object.project
- result = ::ErrorTracking::ListIssuesService.new(
- project,
- context[:current_user],
- args
- ).execute
+ result = ::ErrorTracking::ListIssuesService.new(project, current_user, args).execute
- next_cursor = result[:pagination]&.dig('next', 'cursor')
- previous_cursor = result[:pagination]&.dig('previous', 'cursor')
+ next_cursor = result.dig(:pagination, 'next', 'cursor')
+ previous_cursor = result.dig(:pagination, 'previous', 'cursor')
issues = result[:issues]
# ReactiveCache is still fetching data
@@ -24,6 +31,10 @@ module Resolvers
Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *issues)
end
+
+ def self.field_options
+ super.merge(connection: false) # we manage the pagination manually, so opt out of the connection field extension
+ end
end
end
end
diff --git a/app/graphql/resolvers/group_members_resolver.rb b/app/graphql/resolvers/group_members_resolver.rb
index d3aa376c29c..fcdf7c01d8b 100644
--- a/app/graphql/resolvers/group_members_resolver.rb
+++ b/app/graphql/resolvers/group_members_resolver.rb
@@ -6,6 +6,11 @@ module Resolvers
authorize :read_group_member
+ argument :relations, [Types::GroupMemberRelationEnum],
+ description: 'Filter members by the given member relations',
+ required: false,
+ default_value: GroupMembersFinder::DEFAULT_RELATIONS
+
private
def preloads
diff --git a/app/graphql/resolvers/issue_status_counts_resolver.rb b/app/graphql/resolvers/issue_status_counts_resolver.rb
index 5d0d5693244..58cff559d0d 100644
--- a/app/graphql/resolvers/issue_status_counts_resolver.rb
+++ b/app/graphql/resolvers/issue_status_counts_resolver.rb
@@ -6,6 +6,8 @@ module Resolvers
type Types::IssueStatusCountsType, null: true
+ extras [:lookahead]
+
def continue_issue_resolve(parent, finder, **args)
finder.parent_param = parent
apply_lookahead(Gitlab::IssuablesCountForState.new(finder, parent))
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index dd35219454f..ae27cce9113 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -10,7 +10,7 @@ module Resolvers
argument :sort, Types::IssueSortEnum,
description: 'Sort issues by this criteria',
required: false,
- default_value: 'created_desc'
+ default_value: :created_desc
type Types::IssueType.connection_type, null: true
@@ -24,7 +24,7 @@ module Resolvers
if non_stable_cursor_sort?(args[:sort])
# Certain complex sorts are not supported by the stable cursor pagination yet.
# In these cases, we use offset pagination, so we return the correct connection.
- Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(issues)
+ offset_pagination(issues)
else
issues
end
diff --git a/app/graphql/resolvers/members_resolver.rb b/app/graphql/resolvers/members_resolver.rb
index 523642e912f..cf51fd298bd 100644
--- a/app/graphql/resolvers/members_resolver.rb
+++ b/app/graphql/resolvers/members_resolver.rb
@@ -14,7 +14,9 @@ module Resolvers
def resolve_with_lookahead(**args)
authorize!(object)
- apply_lookahead(finder_class.new(object, current_user, params: args).execute)
+ relations = args.delete(:relations)
+
+ apply_lookahead(finder_class.new(object, current_user, params: args).execute(include_relations: relations))
end
private
diff --git a/app/graphql/resolvers/merge_request_pipelines_resolver.rb b/app/graphql/resolvers/merge_request_pipelines_resolver.rb
index 6590dfdc78c..f84eedb4c3b 100644
--- a/app/graphql/resolvers/merge_request_pipelines_resolver.rb
+++ b/app/graphql/resolvers/merge_request_pipelines_resolver.rb
@@ -5,14 +5,32 @@ module Resolvers
class MergeRequestPipelinesResolver < BaseResolver
# The GraphQL type here gets defined in this include
include ::ResolvesPipelines
+ include ::CachingArrayResolver
alias_method :merge_request, :object
+ # Return at most 500 pipelines for each MR.
+ # Merge requests generally have many fewer pipelines than this.
+ def self.field_options
+ super.merge(max_page_size: 500)
+ end
+
def resolve(**args)
return unless project
- resolve_pipelines(project, args)
- .merge(merge_request.all_pipelines)
+ super
+ end
+
+ def query_for(args)
+ resolve_pipelines(project, args).merge(merge_request.all_pipelines)
+ end
+
+ def model_class
+ ::Ci::Pipeline
+ end
+
+ def query_input(**args)
+ args
end
def project
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index cb4a76243ae..98c95565778 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -4,6 +4,8 @@ module Resolvers
class MergeRequestsResolver < BaseResolver
include ResolvesMergeRequests
+ type ::Types::MergeRequestType.connection_type, null: true
+
alias_method :project, :synchronized_object
def self.accept_assignee
@@ -18,6 +20,12 @@ module Resolvers
description: 'Username of the author'
end
+ def self.accept_reviewer
+ argument :reviewer_username, GraphQL::STRING_TYPE,
+ 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]`'
@@ -52,7 +60,7 @@ module Resolvers
argument :sort, Types::MergeRequestSortEnum,
description: 'Sort merge requests by this criteria',
required: false,
- default_value: 'created_desc'
+ default_value: :created_desc
def self.single
::Resolvers::MergeRequestResolver
diff --git a/app/graphql/resolvers/project_members_resolver.rb b/app/graphql/resolvers/project_members_resolver.rb
index e64e8b845a5..659b12c2563 100644
--- a/app/graphql/resolvers/project_members_resolver.rb
+++ b/app/graphql/resolvers/project_members_resolver.rb
@@ -5,6 +5,11 @@ module Resolvers
class ProjectMembersResolver < MembersResolver
authorize :read_project_member
+ argument :relations, [Types::ProjectMemberRelationEnum],
+ description: 'Filter members by the given member relations',
+ required: false,
+ default_value: MembersFinder::DEFAULT_RELATIONS
+
private
def finder_class
diff --git a/app/graphql/resolvers/project_merge_requests_resolver.rb b/app/graphql/resolvers/project_merge_requests_resolver.rb
index bf082c0b182..830649d5e52 100644
--- a/app/graphql/resolvers/project_merge_requests_resolver.rb
+++ b/app/graphql/resolvers/project_merge_requests_resolver.rb
@@ -5,5 +5,6 @@ module Resolvers
type ::Types::MergeRequestType.connection_type, null: true
accept_assignee
accept_author
+ accept_reviewer
end
end
diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb
index 4cf47dbdc60..8bf4e0b08ef 100644
--- a/app/graphql/resolvers/project_pipeline_resolver.rb
+++ b/app/graphql/resolvers/project_pipeline_resolver.rb
@@ -12,7 +12,9 @@ module Resolvers
def resolve(iid:)
BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args|
- args[:key].all_pipelines.for_iid(iids).each { |pl| loader.call(pl.iid.to_s, pl) }
+ finder = ::Ci::PipelinesFinder.new(project, context[:current_user], iids: iids)
+
+ finder.execute.each { |pipeline| loader.call(pipeline.iid.to_s, pipeline) }
end
end
end
diff --git a/app/graphql/resolvers/project_pipeline_statistics_resolver.rb b/app/graphql/resolvers/project_pipeline_statistics_resolver.rb
new file mode 100644
index 00000000000..29ab9402f5b
--- /dev/null
+++ b/app/graphql/resolvers/project_pipeline_statistics_resolver.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ProjectPipelineStatisticsResolver < BaseResolver
+ type Types::Ci::AnalyticsType, null: true
+
+ def resolve
+ weekly_stats = Gitlab::Ci::Charts::WeekChart.new(object)
+ monthly_stats = Gitlab::Ci::Charts::MonthChart.new(object)
+ yearly_stats = Gitlab::Ci::Charts::YearChart.new(object)
+ pipeline_times = Gitlab::Ci::Charts::PipelineTime.new(object)
+
+ {
+ week_pipelines_labels: weekly_stats.labels,
+ week_pipelines_totals: weekly_stats.total,
+ week_pipelines_successful: weekly_stats.success,
+ month_pipelines_labels: monthly_stats.labels,
+ month_pipelines_totals: monthly_stats.total,
+ month_pipelines_successful: monthly_stats.success,
+ year_pipelines_labels: yearly_stats.labels,
+ year_pipelines_totals: yearly_stats.total,
+ year_pipelines_successful: yearly_stats.success,
+ pipeline_times_labels: pipeline_times.labels,
+ pipeline_times_values: pipeline_times.pipeline_times
+ }
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/jira_imports_resolver.rb b/app/graphql/resolvers/projects/jira_imports_resolver.rb
deleted file mode 100644
index efd45c2c465..00000000000
--- a/app/graphql/resolvers/projects/jira_imports_resolver.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Resolvers
- module Projects
- class JiraImportsResolver < BaseResolver
- type Types::JiraImportType.connection_type, null: true
-
- include Gitlab::Graphql::Authorize::AuthorizeResource
-
- alias_method :project, :object
-
- def resolve(**args)
- authorize!(project)
-
- project.jira_imports
- end
-
- def authorized_resource?(project)
- context[:current_user].present? && Ability.allowed?(context[:current_user], :read_project, project)
- end
- end
- end
-end
diff --git a/app/graphql/resolvers/projects/services_resolver.rb b/app/graphql/resolvers/projects/services_resolver.rb
index 17d81e21c28..4f5a6cddbb3 100644
--- a/app/graphql/resolvers/projects/services_resolver.rb
+++ b/app/graphql/resolvers/projects/services_resolver.rb
@@ -3,9 +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
argument :active,
GraphQL::BOOLEAN_TYPE,
@@ -24,10 +26,6 @@ module Resolvers
services(args[:active], args[:type])
end
- def authorized_resource?(project)
- Ability.allowed?(context[:current_user], :admin_project, project)
- end
-
private
def services(active, type)
diff --git a/app/graphql/resolvers/review_requested_merge_requests_resolver.rb b/app/graphql/resolvers/review_requested_merge_requests_resolver.rb
new file mode 100644
index 00000000000..e0ab7b5b600
--- /dev/null
+++ b/app/graphql/resolvers/review_requested_merge_requests_resolver.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class ReviewRequestedMergeRequestsResolver < UserMergeRequestsResolverBase
+ type ::Types::MergeRequestType.connection_type, null: true
+ accept_author
+ accept_assignee
+
+ def user_role
+ :reviewer
+ end
+ end
+end
diff --git a/app/graphql/resolvers/snippets/blobs_resolver.rb b/app/graphql/resolvers/snippets/blobs_resolver.rb
index 3a0dcb50faf..cfb1711aed4 100644
--- a/app/graphql/resolvers/snippets/blobs_resolver.rb
+++ b/app/graphql/resolvers/snippets/blobs_resolver.rb
@@ -3,9 +3,11 @@
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
alias_method :snippet, :object
@@ -27,10 +29,6 @@ module Resolvers
end
end
- def authorized_resource?(snippet)
- Ability.allowed?(context[:current_user], :read_snippet, snippet)
- end
-
private
def transformed_blob_paths(paths)
diff --git a/app/graphql/resolvers/user_discussions_count_resolver.rb b/app/graphql/resolvers/user_discussions_count_resolver.rb
new file mode 100644
index 00000000000..115997ec666
--- /dev/null
+++ b/app/graphql/resolvers/user_discussions_count_resolver.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class UserDiscussionsCountResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type GraphQL::INT_TYPE, null: true
+
+ def resolve
+ authorize!(object)
+
+ BatchLoader::GraphQL.for(object.id).batch do |ids, loader, args|
+ counts = Note.count_for_collection(ids, object.class.name, 'COUNT(DISTINCT discussion_id) as count').index_by(&:noteable_id)
+
+ ids.each do |id|
+ loader.call(id, counts[id]&.count || 0)
+ end
+ end
+ end
+
+ def authorized_resource?(object)
+ ability = "read_#{object.class.name.underscore}".to_sym
+ context[:current_user].present? && Ability.allowed?(context[:current_user], ability, object)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/user_notes_count_resolver.rb b/app/graphql/resolvers/user_notes_count_resolver.rb
new file mode 100644
index 00000000000..2cb61104c18
--- /dev/null
+++ b/app/graphql/resolvers/user_notes_count_resolver.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class UserNotesCountResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ type GraphQL::INT_TYPE, null: true
+
+ def resolve
+ authorize!(object)
+
+ BatchLoader::GraphQL.for(object.id).batch(key: :user_notes_count) do |ids, loader, args|
+ counts = Note.count_for_collection(ids, object.class.name).index_by(&:noteable_id)
+
+ ids.each do |id|
+ loader.call(id, counts[id]&.count || 0)
+ end
+ end
+ end
+
+ def authorized_resource?(object)
+ ability = "read_#{object.class.name.underscore}".to_sym
+ context[:current_user].present? && Ability.allowed?(context[:current_user], ability, object)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/users/group_count_resolver.rb b/app/graphql/resolvers/users/group_count_resolver.rb
index 5033c26554a..ebfe594d31d 100644
--- a/app/graphql/resolvers/users/group_count_resolver.rb
+++ b/app/graphql/resolvers/users/group_count_resolver.rb
@@ -3,6 +3,8 @@
module Resolvers
module Users
class GroupCountResolver < BaseResolver
+ type GraphQL::INT_TYPE, null: true
+
alias_method :user, :object
def resolve(**args)
diff --git a/app/graphql/resolvers/users_resolver.rb b/app/graphql/resolvers/users_resolver.rb
index f5838642141..a0ed076595d 100644
--- a/app/graphql/resolvers/users_resolver.rb
+++ b/app/graphql/resolvers/users_resolver.rb
@@ -17,7 +17,7 @@ module Resolvers
argument :sort, Types::SortEnum,
description: 'Sort users by this criteria',
required: false,
- default_value: 'created_desc'
+ default_value: :created_desc
argument :search, GraphQL::STRING_TYPE,
required: false,
diff --git a/app/graphql/types/alert_management/domain_filter_enum.rb b/app/graphql/types/alert_management/domain_filter_enum.rb
new file mode 100644
index 00000000000..58dbc8bb2cf
--- /dev/null
+++ b/app/graphql/types/alert_management/domain_filter_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module AlertManagement
+ class DomainFilterEnum < BaseEnum
+ graphql_name 'AlertManagementDomainFilter'
+ description 'Filters the alerts based on given domain'
+
+ value 'operations', description: 'Alerts for operations domain '
+ value 'threat_monitoring', description: 'Alerts for threat monitoring domain'
+ end
+ end
+end
diff --git a/app/graphql/types/alert_management/prometheus_integration_type.rb b/app/graphql/types/alert_management/prometheus_integration_type.rb
index f605e325b8b..79f265f2f1e 100644
--- a/app/graphql/types/alert_management/prometheus_integration_type.rb
+++ b/app/graphql/types/alert_management/prometheus_integration_type.rb
@@ -2,7 +2,7 @@
module Types
module AlertManagement
- class PrometheusIntegrationType < BaseObject
+ class PrometheusIntegrationType < ::Types::BaseObject
include ::Gitlab::Routing
graphql_name 'AlertManagementPrometheusIntegration'
diff --git a/app/graphql/types/award_emojis/award_emoji_type.rb b/app/graphql/types/award_emojis/award_emoji_type.rb
index fe7affa50cc..cd7a2f34ba6 100644
--- a/app/graphql/types/award_emojis/award_emoji_type.rb
+++ b/app/graphql/types/award_emojis/award_emoji_type.rb
@@ -38,10 +38,11 @@ module Types
field :user,
Types::UserType,
null: false,
- description: 'The user who awarded the emoji',
- resolve: -> (award_emoji, _args, _context) {
- Gitlab::Graphql::Loaders::BatchModelLoader.new(User, award_emoji.user_id).find
- }
+ description: 'The user who awarded the emoji'
+
+ def user
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find
+ end
end
end
end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 5c8aabfe163..c4ce2cecd8b 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -12,6 +12,7 @@ module Types
def initialize(*args, **kwargs, &block)
@calls_gitaly = !!kwargs.delete(:calls_gitaly)
@constant_complexity = !!kwargs[:complexity]
+ @requires_argument = !!kwargs.delete(:requires_argument)
kwargs[:complexity] = field_complexity(kwargs[:resolver_class], kwargs[:complexity])
@feature_flag = kwargs[:feature_flag]
kwargs = check_feature_flag(kwargs)
@@ -20,6 +21,10 @@ module Types
super(*args, **kwargs, &block)
end
+ def requires_argument?
+ @requires_argument || arguments.values.any? { |argument| argument.type.non_null? }
+ end
+
# Based on https://github.com/rmosolgo/graphql-ruby/blob/v1.11.4/lib/graphql/schema/field.rb#L538-L563
# Modified to fix https://github.com/rmosolgo/graphql-ruby/issues/3113
def resolve_field(obj, args, ctx)
@@ -73,7 +78,7 @@ module Types
attr_reader :feature_flag
def feature_documentation_message(key, description)
- "#{description}. Available only when feature flag `#{key}` is enabled"
+ "#{description} Available only when feature flag `#{key}` is enabled."
end
def check_feature_flag(args)
diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb
index 3451a195c33..4b1f3193136 100644
--- a/app/graphql/types/base_interface.rb
+++ b/app/graphql/types/base_interface.rb
@@ -3,5 +3,7 @@
module Types
module BaseInterface
include GraphQL::Schema::Interface
+
+ field_class ::Types::BaseField
end
end
diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb
index 6ee76b0d1f1..7999e77eb30 100644
--- a/app/graphql/types/board_list_type.rb
+++ b/app/graphql/types/board_list_type.rb
@@ -19,8 +19,7 @@ module Types
field :label, Types::LabelType, null: true,
description: 'Label of the list'
field :collapsed, GraphQL::BOOLEAN_TYPE, null: true,
- description: 'Indicates if list is collapsed for this user',
- resolve: -> (list, _args, ctx) { list.collapsed?(ctx[:current_user]) }
+ description: 'Indicates if list is collapsed for this user'
field :issues_count, GraphQL::INT_TYPE, null: true,
description: 'Count of issues in the list'
@@ -32,6 +31,10 @@ module Types
metadata[:size]
end
+ def collapsed
+ object.collapsed?(context[:current_user])
+ end
+
def metadata
strong_memoize(:metadata) do
list = self.object
diff --git a/app/graphql/types/board_type.rb b/app/graphql/types/board_type.rb
index 2a7b318e283..f47c744d1bb 100644
--- a/app/graphql/types/board_type.rb
+++ b/app/graphql/types/board_type.rb
@@ -12,6 +12,12 @@ module Types
field :name, type: GraphQL::STRING_TYPE, null: true,
description: 'Name of the board'
+ field :hide_backlog_list, type: GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Whether or not backlog list is hidden'
+
+ field :hide_closed_list, type: GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Whether or not closed list is hidden'
+
field :lists,
Types::BoardListType.connection_type,
null: true,
diff --git a/app/graphql/types/ci/analytics_type.rb b/app/graphql/types/ci/analytics_type.rb
new file mode 100644
index 00000000000..c8b12c6a9b8
--- /dev/null
+++ b/app/graphql/types/ci/analytics_type.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class AnalyticsType < BaseObject
+ graphql_name 'PipelineAnalytics'
+
+ field :week_pipelines_totals, [GraphQL::INT_TYPE], null: true,
+ description: 'Total weekly pipeline count'
+ field :week_pipelines_successful, [GraphQL::INT_TYPE], null: true,
+ description: 'Total weekly successful pipeline count'
+ field :week_pipelines_labels, [GraphQL::STRING_TYPE], null: true,
+ description: 'Labels for the weekly pipeline count'
+ field :month_pipelines_totals, [GraphQL::INT_TYPE], null: true,
+ description: 'Total monthly pipeline count'
+ field :month_pipelines_successful, [GraphQL::INT_TYPE], null: true,
+ description: 'Total monthly successful pipeline count'
+ field :month_pipelines_labels, [GraphQL::STRING_TYPE], null: true,
+ description: 'Labels for the monthly pipeline count'
+ field :year_pipelines_totals, [GraphQL::INT_TYPE], null: true,
+ description: 'Total yearly pipeline count'
+ field :year_pipelines_successful, [GraphQL::INT_TYPE], null: true,
+ description: 'Total yearly successful pipeline count'
+ field :year_pipelines_labels, [GraphQL::STRING_TYPE], null: true,
+ description: 'Labels for the yearly pipeline count'
+ field :pipeline_times_values, [GraphQL::INT_TYPE], null: true,
+ description: 'Pipeline times'
+ field :pipeline_times_labels, [GraphQL::STRING_TYPE], null: true,
+ description: 'Pipeline times labels'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/ci_cd_setting_type.rb b/app/graphql/types/ci/ci_cd_setting_type.rb
new file mode 100644
index 00000000000..207c37f9538
--- /dev/null
+++ b/app/graphql/types/ci/ci_cd_setting_type.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class CiCdSettingType < BaseObject
+ graphql_name 'ProjectCiCdSetting'
+
+ authorize :admin_project
+
+ field :merge_pipelines_enabled, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Whether merge pipelines are enabled.',
+ method: :merge_pipelines_enabled?
+ field :merge_trains_enabled, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Whether merge trains are enabled.',
+ method: :merge_trains_enabled?
+ field :project, Types::ProjectType, null: true,
+ description: 'Project the CI/CD settings belong to.'
+ end
+ end
+end
diff --git a/app/graphql/types/ci/config/config_type.rb b/app/graphql/types/ci/config/config_type.rb
new file mode 100644
index 00000000000..e54b345f3d3
--- /dev/null
+++ b/app/graphql/types/ci/config/config_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ module Config
+ class ConfigType < BaseObject
+ graphql_name 'CiConfig'
+
+ field :errors, [GraphQL::STRING_TYPE], null: true,
+ description: 'Linting errors'
+ field :merged_yaml, GraphQL::STRING_TYPE, null: true,
+ description: 'Merged CI config YAML'
+ field :stages, [Types::Ci::Config::StageType], null: true,
+ description: 'Stages of the pipeline'
+ field :status, Types::Ci::Config::StatusEnum, null: true,
+ description: 'Status of linting, can be either valid or invalid'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/config/group_type.rb b/app/graphql/types/ci/config/group_type.rb
new file mode 100644
index 00000000000..8b0db2934a4
--- /dev/null
+++ b/app/graphql/types/ci/config/group_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ module Config
+ class GroupType < BaseObject
+ graphql_name 'CiConfigGroup'
+
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the job group'
+ field :jobs, [Types::Ci::Config::JobType], null: true,
+ description: 'Jobs in group'
+ field :size, GraphQL::INT_TYPE, null: true,
+ description: 'Size of the job group'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/config/job_type.rb b/app/graphql/types/ci/config/job_type.rb
new file mode 100644
index 00000000000..59bcbd9ef49
--- /dev/null
+++ b/app/graphql/types/ci/config/job_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ module Config
+ class JobType < BaseObject
+ graphql_name 'CiConfigJob'
+
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the job'
+ field :group_name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the job group'
+ field :stage, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the job stage'
+ field :needs, [Types::Ci::Config::NeedType], null: true,
+ description: 'Builds that must complete before the jobs run'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/config/need_type.rb b/app/graphql/types/ci/config/need_type.rb
new file mode 100644
index 00000000000..a442450b9ae
--- /dev/null
+++ b/app/graphql/types/ci/config/need_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ module Config
+ class NeedType < BaseObject
+ graphql_name 'CiConfigNeed'
+
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the need'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/config/stage_type.rb b/app/graphql/types/ci/config/stage_type.rb
new file mode 100644
index 00000000000..20618bc41f8
--- /dev/null
+++ b/app/graphql/types/ci/config/stage_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ module Config
+ class StageType < BaseObject
+ graphql_name 'CiConfigStage'
+
+ field :name, GraphQL::STRING_TYPE, null: true,
+ description: 'Name of the stage'
+ field :groups, [Types::Ci::Config::GroupType], null: true,
+ description: 'Groups of jobs for the stage'
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/config/status_enum.rb b/app/graphql/types/ci/config/status_enum.rb
new file mode 100644
index 00000000000..92b04c61679
--- /dev/null
+++ b/app/graphql/types/ci/config/status_enum.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ module Config
+ class StatusEnum < BaseEnum
+ graphql_name 'CiConfigStatus'
+ description 'Values for YAML processor result'
+
+ value 'VALID', 'The configuration file is valid', value: :valid
+ value 'INVALID', 'The configuration file is not valid', value: :invalid
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index 6d8af400ac4..80d73e9b174 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -25,20 +25,22 @@ module Types
description: 'Tooltip associated with the status',
method: :status_tooltip
field :action, Types::Ci::StatusActionType, null: true,
- description: 'Action information for the status. This includes method, button title, icon, path, and title',
- resolve: -> (obj, _args, _ctx) {
- if obj.has_action?
- {
- button_title: obj.action_button_title,
- icon: obj.action_icon,
- method: obj.action_method,
- path: obj.action_path,
- title: obj.action_title
- }
- else
- nil
- end
- }
+ calls_gitaly: true,
+ description: 'Action information for the status. This includes method, button title, icon, path, and title'
+
+ def action
+ if object.has_action?
+ {
+ button_title: object.action_button_title,
+ icon: object.action_icon,
+ method: object.action_method,
+ path: object.action_path,
+ title: object.action_title
+ }
+ else
+ nil
+ end
+ end
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb
index d930ae311b7..03fd50d5dbb 100644
--- a/app/graphql/types/ci/group_type.rb
+++ b/app/graphql/types/ci/group_type.rb
@@ -13,8 +13,11 @@ module Types
field :jobs, Ci::JobType.connection_type, null: true,
description: 'Jobs in group'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the group',
- resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
+ description: 'Detailed status of the group'
+
+ def detailed_status
+ object.detailed_status(context[:current_user])
+ end
end
end
end
diff --git a/app/graphql/types/ci/job_artifact_file_type_enum.rb b/app/graphql/types/ci/job_artifact_file_type_enum.rb
new file mode 100644
index 00000000000..4b484dec590
--- /dev/null
+++ b/app/graphql/types/ci/job_artifact_file_type_enum.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class JobArtifactFileTypeEnum < BaseEnum
+ graphql_name 'JobArtifactFileType'
+
+ ::Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.keys.each do |file_type|
+ value file_type.to_s.upcase, value: file_type.to_s
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/job_artifact_type.rb b/app/graphql/types/ci/job_artifact_type.rb
new file mode 100644
index 00000000000..c34a12dcc61
--- /dev/null
+++ b/app/graphql/types/ci/job_artifact_type.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ class JobArtifactType < BaseObject
+ graphql_name 'CiJobArtifact'
+
+ field :download_path, GraphQL::STRING_TYPE, null: true,
+ description: "URL for downloading the artifact's file"
+
+ field :file_type, ::Types::Ci::JobArtifactFileTypeEnum, null: true,
+ description: 'File type of the artifact'
+
+ def download_path
+ ::Gitlab::Routing.url_helpers.download_project_job_artifacts_path(
+ object.project,
+ object.job,
+ file_type: object.file_type
+ )
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index feaff4e81d8..5b6e8fe8567 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -6,18 +6,32 @@ module Types
class JobType < BaseObject
graphql_name 'CiJob'
- field :pipeline, Types::Ci::PipelineType, null: false,
- description: 'Pipeline the job belongs to',
- resolve: -> (build, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, build.pipeline_id).find }
+ field :pipeline, Types::Ci::PipelineType, null: true,
+ description: 'Pipeline the job belongs to'
field :name, GraphQL::STRING_TYPE, null: true,
- description: 'Name of the job'
+ description: 'Name of the job'
field :needs, JobType.connection_type, null: true,
- description: 'Builds that must complete before the jobs run'
+ description: 'Builds that must complete before the jobs run'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the job',
- resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
+ description: 'Detailed status of the job'
field :scheduled_at, Types::TimeType, null: true,
- description: 'Schedule for the build'
+ description: 'Schedule for the build'
+ field :artifacts, Types::Ci::JobArtifactType.connection_type, null: true,
+ description: 'Artifacts generated by the job'
+
+ def pipeline
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, object.pipeline_id).find
+ end
+
+ def detailed_status
+ object.detailed_status(context[:current_user])
+ end
+
+ def artifacts
+ if object.is_a?(::Ci::Build)
+ object.job_artifacts
+ end
+ end
end
end
end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index c25db39f600..4709d5e8dd6 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -27,8 +27,7 @@ module Types
description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})"
field :detailed_status, Types::Ci::DetailedStatusType, null: false,
- description: 'Detailed status of the pipeline',
- resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
+ description: 'Detailed status of the pipeline'
field :config_source, PipelineConfigSourceEnum, null: true,
description: "Config source of the pipeline (#{::Enums::Ci::Pipeline.config_sources.keys.join(', ').upcase})"
@@ -60,8 +59,7 @@ module Types
resolver: Resolvers::Ci::PipelineStagesResolver
field :user, Types::UserType, null: true,
- description: 'Pipeline user',
- resolve: -> (pipeline, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, pipeline.user_id).find }
+ description: 'Pipeline user'
field :retryable, GraphQL::BOOLEAN_TYPE,
description: 'Specifies if a pipeline can be retried',
@@ -91,11 +89,25 @@ module Types
method: :triggered_by_pipeline
field :path, GraphQL::STRING_TYPE, null: true,
- description: "Relative path to the pipeline's page",
- resolve: -> (obj, _args, _ctx) { ::Gitlab::Routing.url_helpers.project_pipeline_path(obj.project, obj) }
+ description: "Relative path to the pipeline's page"
field :project, Types::ProjectType, null: true,
description: 'Project the pipeline belongs to'
+
+ field :active, GraphQL::BOOLEAN_TYPE, null: false, method: :active?,
+ description: 'Indicates if the pipeline is active'
+
+ def detailed_status
+ object.detailed_status(context[:current_user])
+ end
+
+ def user
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find
+ end
+
+ def path
+ ::Gitlab::Routing.url_helpers.project_pipeline_path(object.project, object)
+ end
end
end
end
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index fc2c72d0d06..fd0bde90836 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -11,8 +11,11 @@ module Types
field :groups, Ci::GroupType.connection_type, null: true,
description: 'Group of jobs for the stage'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
- description: 'Detailed status of the stage',
- resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
+ description: 'Detailed status of the stage'
+
+ def detailed_status
+ object.detailed_status(context[:current_user])
+ end
end
end
end
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
index c24b47f08ef..37d19b4148b 100644
--- a/app/graphql/types/commit_type.rb
+++ b/app/graphql/types/commit_type.rb
@@ -12,6 +12,8 @@ module Types
description: 'ID (global ID) of the commit'
field :sha, type: GraphQL::STRING_TYPE, null: false,
description: 'SHA1 ID of the commit'
+ field :short_id, type: GraphQL::STRING_TYPE, null: false,
+ description: 'Short SHA1 ID of the commit'
field :title, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true,
description: 'Title of the commit message'
markdown_field :title_html, null: true
@@ -31,10 +33,7 @@ module Types
field :author_name, type: GraphQL::STRING_TYPE, null: true,
description: 'Commit authors name'
field :author_gravatar, type: GraphQL::STRING_TYPE, null: true,
- description: 'Commit authors gravatar',
- resolve: -> (commit, args, context) do
- GravatarService.new.execute(commit.author_email, 40)
- end
+ description: 'Commit authors gravatar'
# models/commit lazy loads the author by email
field :author, type: Types::UserType, null: true,
@@ -44,5 +43,9 @@ module Types
null: true,
description: 'Pipelines of the commit ordered latest first',
resolver: Resolvers::CommitPipelinesResolver
+
+ def author_gravatar
+ GravatarService.new.execute(object.author_email, 40)
+ end
end
end
diff --git a/app/graphql/types/concerns/gitlab_style_deprecations.rb b/app/graphql/types/concerns/gitlab_style_deprecations.rb
index 2c932f4214b..9f087f3812d 100644
--- a/app/graphql/types/concerns/gitlab_style_deprecations.rb
+++ b/app/graphql/types/concerns/gitlab_style_deprecations.rb
@@ -23,8 +23,8 @@ module GitlabStyleDeprecations
raise ArgumentError, '`milestone` must be a `String`' unless milestone.is_a?(String)
deprecated_in = "Deprecated in #{milestone}"
- kwargs[:deprecation_reason] = "#{reason}. #{deprecated_in}"
- kwargs[:description] += ". #{deprecated_in}: #{reason}" if kwargs[:description]
+ kwargs[:deprecation_reason] = "#{reason}. #{deprecated_in}."
+ kwargs[:description] += " #{deprecated_in}: #{reason}." if kwargs[:description]
kwargs
end
diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb
index 45d19fdbc50..8735f8a173d 100644
--- a/app/graphql/types/container_repository_type.rb
+++ b/app/graphql/types/container_repository_type.rb
@@ -19,9 +19,14 @@ module Types
field :status, Types::ContainerRepositoryStatusEnum, null: true, description: 'Status of the container repository.'
field :tags_count, GraphQL::INT_TYPE, null: false, description: 'Number of tags associated with this image.'
field :can_delete, GraphQL::BOOLEAN_TYPE, null: false, description: 'Can the current user delete the container repository.'
+ field :project, Types::ProjectType, null: false, description: 'Project of the container registry'
def can_delete
Ability.allowed?(current_user, :update_container_image, object)
end
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
+ end
end
end
diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb
index 9af1f4db425..26fbac15b30 100644
--- a/app/graphql/types/design_management/design_collection_type.rb
+++ b/app/graphql/types/design_management/design_collection_type.rb
@@ -2,7 +2,7 @@
module Types
module DesignManagement
- class DesignCollectionType < BaseObject
+ class DesignCollectionType < ::Types::BaseObject
graphql_name 'DesignCollection'
description 'A collection of designs'
diff --git a/app/graphql/types/error_tracking/sentry_error_collection_type.rb b/app/graphql/types/error_tracking/sentry_error_collection_type.rb
index 798e0433d06..49d5d62c860 100644
--- a/app/graphql/types/error_tracking/sentry_error_collection_type.rb
+++ b/app/graphql/types/error_tracking/sentry_error_collection_type.rb
@@ -9,27 +9,12 @@ module Types
authorize :read_sentry_issue
field :errors,
- Types::ErrorTracking::SentryErrorType.connection_type,
- connection: false,
- null: true,
description: "Collection of Sentry Errors",
- extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension],
- resolver: Resolvers::ErrorTracking::SentryErrorsResolver do
- argument :search_term,
- String,
- description: 'Search query for the Sentry error details',
- required: false
- argument :sort,
- String,
- description: 'Attribute to sort on. Options are frequency, first_seen, last_seen. last_seen is default',
- required: false
- end
- field :detailed_error, Types::ErrorTracking::SentryDetailedErrorType,
- null: true,
+ resolver: Resolvers::ErrorTracking::SentryErrorsResolver
+ field :detailed_error,
description: 'Detailed version of a Sentry error on the project',
resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
- field :error_stack_trace, Types::ErrorTracking::SentryErrorStackTraceType,
- null: true,
+ field :error_stack_trace,
description: 'Stack Trace of Sentry Error',
resolver: Resolvers::ErrorTracking::SentryErrorStackTraceResolver
field :external_url,
diff --git a/app/graphql/types/group_invitation_type.rb b/app/graphql/types/group_invitation_type.rb
index 0372ce178ff..efb0c8a41c8 100644
--- a/app/graphql/types/group_invitation_type.rb
+++ b/app/graphql/types/group_invitation_type.rb
@@ -11,7 +11,10 @@ module Types
description 'Represents a Group Invitation'
field :group, Types::GroupType, null: true,
- description: 'Group that a User is invited to',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.source_id).find }
+ description: 'Group that a User is invited to'
+
+ def group
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find
+ end
end
end
diff --git a/app/graphql/types/group_member_relation_enum.rb b/app/graphql/types/group_member_relation_enum.rb
new file mode 100644
index 00000000000..aa2e73d4944
--- /dev/null
+++ b/app/graphql/types/group_member_relation_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class GroupMemberRelationEnum < BaseEnum
+ graphql_name 'GroupMemberRelation'
+ description 'Group member relation'
+
+ ::GroupMembersFinder::RELATIONS.each do |member_relation|
+ value member_relation.to_s.upcase, value: member_relation, description: "#{member_relation.to_s.titleize} members"
+ end
+ end
+end
diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb
index 6cca0a50647..204da5a302a 100644
--- a/app/graphql/types/group_member_type.rb
+++ b/app/graphql/types/group_member_type.rb
@@ -11,7 +11,10 @@ module Types
description 'Represents a Group Membership'
field :group, Types::GroupType, null: true,
- description: 'Group that a User is a member of',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.source_id).find }
+ description: 'Group that a User is a member of'
+
+ def group
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find
+ end
end
end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index fb028184488..0ee8a19c1a3 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -12,10 +12,7 @@ module Types
description: 'Web URL of the group'
field :avatar_url, GraphQL::STRING_TYPE, null: true,
- description: 'Avatar URL of the group',
- resolve: -> (group, args, ctx) do
- group.avatar_url(only_path: false)
- end
+ description: 'Avatar URL of the group'
field :custom_emoji, Types::CustomEmojiType.connection_type, null: true,
description: 'Custom emoji within this namespace',
@@ -44,8 +41,7 @@ module Types
description: 'Indicates if a group is disabled from getting mentioned'
field :parent, GroupType, null: true,
- description: 'Parent group',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find }
+ description: 'Parent group'
field :issues,
Types::IssueType.connection_type,
@@ -92,10 +88,13 @@ module Types
field :container_repositories,
Types::ContainerRepositoryType.connection_type,
null: true,
- description: 'Container repositories of the project',
+ description: 'Container repositories of the group',
resolver: Resolvers::ContainerRepositoriesResolver,
authorize: :read_container_image
+ field :container_repositories_count, GraphQL::INT_TYPE, null: false,
+ description: 'Number of container repositories in the group'
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder
@@ -120,6 +119,18 @@ module Types
.execute
end
+ def avatar_url
+ object.avatar_url(only_path: false)
+ end
+
+ def parent
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.parent_id).find
+ end
+
+ def container_repositories_count
+ group.container_repositories.size
+ end
+
private
def group
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 49c84f75e1a..83b8a834801 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -61,9 +61,11 @@ module Types
field :downvotes, GraphQL::INT_TYPE, null: false,
description: 'Number of downvotes the issue has received'
field :user_notes_count, GraphQL::INT_TYPE, null: false,
- description: 'Number of user notes of the issue'
+ description: 'Number of user notes of the issue',
+ resolver: Resolvers::UserNotesCountResolver
field :user_discussions_count, GraphQL::INT_TYPE, null: false,
- description: 'Number of user discussions in the issue'
+ description: 'Number of user discussions in the issue',
+ resolver: Resolvers::UserDiscussionsCountResolver
field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path,
description: 'Web path of the issue'
field :web_url, GraphQL::STRING_TYPE, null: false,
@@ -119,26 +121,6 @@ module Types
field :moved_to, Types::IssueType, null: true,
description: 'Updated Issue after it got moved to another project'
- def user_notes_count
- BatchLoader::GraphQL.for(object.id).batch(key: :issue_user_notes_count) do |ids, loader, args|
- counts = Note.count_for_collection(ids, 'Issue').index_by(&:noteable_id)
-
- ids.each do |id|
- loader.call(id, counts[id]&.count || 0)
- end
- end
- end
-
- def user_discussions_count
- BatchLoader::GraphQL.for(object.id).batch(key: :issue_user_discussions_count) do |ids, loader, args|
- counts = Note.count_for_collection(ids, 'Issue', 'COUNT(DISTINCT discussion_id) as count').index_by(&:noteable_id)
-
- ids.each do |id|
- loader.call(id, counts[id]&.count || 0)
- end
- end
- end
-
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
diff --git a/app/graphql/types/jira_import_type.rb b/app/graphql/types/jira_import_type.rb
index cf58a53b40d..b3854487cec 100644
--- a/app/graphql/types/jira_import_type.rb
+++ b/app/graphql/types/jira_import_type.rb
@@ -2,8 +2,7 @@
module Types
# rubocop: disable Graphql/AuthorizeTypes
- # Authorization is at project level for owners or admins,
- # so it is added directly to the Resolvers::JiraImportsResolver
+ # Authorization is at project level for owners or admins
class JiraImportType < BaseObject
graphql_name 'JiraImport'
diff --git a/app/graphql/types/jira_users_mapping_input_type.rb b/app/graphql/types/jira_users_mapping_input_type.rb
index 61cf1474493..d5b4b2f618a 100644
--- a/app/graphql/types/jira_users_mapping_input_type.rb
+++ b/app/graphql/types/jira_users_mapping_input_type.rb
@@ -8,7 +8,7 @@ module Types
argument :jira_account_id,
GraphQL::STRING_TYPE,
required: true,
- description: 'Jira account id of the user'
+ description: 'Jira account ID of the user'
argument :gitlab_id,
GraphQL::INT_TYPE,
required: false,
diff --git a/app/graphql/types/merge_request_connection_type.rb b/app/graphql/types/merge_request_connection_type.rb
new file mode 100644
index 00000000000..da06bb86929
--- /dev/null
+++ b/app/graphql/types/merge_request_connection_type.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ class MergeRequestConnectionType < Types::CountableConnectionType
+ field :total_time_to_merge, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Total sum of time to merge, in seconds, for the collection of merge requests'
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def total_time_to_merge
+ object.items.reorder(nil).total_time_to_merge
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index e68d6706c43..816160e58f7 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -4,7 +4,7 @@ module Types
class MergeRequestType < BaseObject
graphql_name 'MergeRequest'
- connection_type_class(Types::CountableConnectionType)
+ connection_type_class(Types::MergeRequestConnectionType)
implements(Types::Notes::NoteableType)
implements(Types::CurrentUserTodos)
@@ -49,6 +49,8 @@ module Types
description: 'ID of the merge request target project'
field :source_branch, GraphQL::STRING_TYPE, null: false,
description: 'Source branch of the merge request'
+ field :source_branch_protected, GraphQL::BOOLEAN_TYPE, null: false, calls_gitaly: true,
+ description: 'Indicates if the source branch is protected'
field :target_branch, GraphQL::STRING_TYPE, null: false,
description: 'Target branch of the merge request'
field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false,
@@ -67,9 +69,11 @@ module Types
field :merge_commit_sha, GraphQL::STRING_TYPE, null: true,
description: 'SHA of the merge request commit (set once merged)'
field :user_notes_count, GraphQL::INT_TYPE, null: true,
- description: 'User notes count of the merge request'
+ description: 'User notes count of the merge request',
+ resolver: Resolvers::UserNotesCountResolver
field :user_discussions_count, GraphQL::INT_TYPE, null: true,
- description: 'Number of user discussions in the merge request'
+ description: 'Number of user discussions in the merge request',
+ resolver: Resolvers::UserDiscussionsCountResolver
field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true,
description: 'Indicates if the source branch of the merge request will be deleted after merge'
field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true,
@@ -90,6 +94,8 @@ module Types
description: 'Indicates if there is a rebase currently in progress for the merge request'
field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true,
description: 'Default merge commit message of the merge request'
+ field :default_merge_commit_message_with_description, GraphQL::STRING_TYPE, null: true,
+ description: 'Default merge commit message of the merge request with description'
field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false,
description: 'Indicates if a merge is currently occurring'
field :source_branch_exists, GraphQL::BOOLEAN_TYPE,
@@ -113,7 +119,7 @@ module Types
description: 'The pipeline running on the branch HEAD of the merge request'
field :pipelines,
null: true,
- description: 'Pipelines for the merge request',
+ description: 'Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned.',
resolver: Resolvers::MergeRequestPipelinesResolver
field :milestone, Types::MilestoneType, null: true,
@@ -130,8 +136,7 @@ module Types
description: 'Labels of the merge request'
field :discussion_locked, GraphQL::BOOLEAN_TYPE,
description: 'Indicates if comments on the merge request are locked to members only',
- null: false,
- resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked }
+ null: false
field :time_estimate, GraphQL::INT_TYPE, null: false,
description: 'Time estimate of the merge request'
field :total_time_spent, GraphQL::INT_TYPE, null: false,
@@ -152,6 +157,18 @@ module Types
field :approved_by, Types::UserType.connection_type, null: true,
description: 'Users who approved the merge request'
+ field :squash_on_merge, GraphQL::BOOLEAN_TYPE, null: false, method: :squash_on_merge?,
+ description: 'Indicates if squash on merge is enabled'
+ field :available_auto_merge_strategies, [GraphQL::STRING_TYPE], null: true, calls_gitaly: true,
+ description: 'Array of available auto merge strategies'
+ field :has_ci, GraphQL::BOOLEAN_TYPE, null: false, method: :has_ci?,
+ description: 'Indicates if the merge request has CI'
+ field :mergeable, GraphQL::BOOLEAN_TYPE, null: false, method: :mergeable?, calls_gitaly: true,
+ description: 'Indicates if the merge request is mergeable'
+ field :commits_without_merge_commits, Types::CommitType.connection_type, null: true,
+ calls_gitaly: true, description: 'Merge request commits excluding merge commits'
+ field :security_auto_fix, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if the merge request is created by @GitLab-Security-Bot.'
def approved_by
object.approved_by_users
@@ -194,6 +211,31 @@ module Types
def commit_count
object&.metrics&.commits_count
end
+
+ def source_branch_protected
+ object.source_project.present? && ProtectedBranch.protected?(object.source_project, object.source_branch)
+ end
+
+ def discussion_locked
+ !!object.discussion_locked
+ end
+
+ def default_merge_commit_message_with_description
+ object.default_merge_commit_message(include_description: true)
+ end
+
+ def available_auto_merge_strategies
+ AutoMergeService.new(object.project, current_user).available_strategies(object)
+ end
+
+ def commits_without_merge_commits
+ object.recent_commits.without_merge_commits
+ end
+
+ def security_auto_fix
+ object.author == User.security_bot
+ end
end
end
+
Types::MergeRequestType.prepend_if_ee('::EE::Types::MergeRequestType')
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 75ccac6d590..9eea81c9d3e 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -31,6 +31,7 @@ module Types
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji
mount_mutation Mutations::Discussions::ToggleResolve
+ mount_mutation Mutations::Environments::CanaryIngress::Update
mount_mutation Mutations::Issues::Create
mount_mutation Mutations::Issues::SetAssignees
mount_mutation Mutations::Issues::SetConfidential
@@ -65,6 +66,8 @@ module Types
mount_mutation Mutations::Notes::RepositionImageDiffNote
mount_mutation Mutations::Notes::Destroy
mount_mutation Mutations::Releases::Create
+ mount_mutation Mutations::Releases::Update
+ mount_mutation Mutations::Releases::Delete
mount_mutation Mutations::Terraform::State::Delete
mount_mutation Mutations::Terraform::State::Lock
mount_mutation Mutations::Terraform::State::Unlock
@@ -84,6 +87,7 @@ module Types
mount_mutation Mutations::DesignManagement::Move
mount_mutation Mutations::ContainerExpirationPolicies::Update
mount_mutation Mutations::ContainerRepositories::Destroy
+ mount_mutation Mutations::ContainerRepositories::DestroyTags
mount_mutation Mutations::Ci::PipelineCancel
mount_mutation Mutations::Ci::PipelineDestroy
mount_mutation Mutations::Ci::PipelineRetry
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index fbdf049b755..4dec6f4c5e6 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -21,6 +21,7 @@ module Types
field :description, GraphQL::STRING_TYPE, null: true,
description: 'Description of the namespace'
markdown_field :description_html, null: true
+
field :visibility, GraphQL::STRING_TYPE, null: true,
description: 'Visibility of the namespace'
field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?,
@@ -30,12 +31,15 @@ module Types
field :root_storage_statistics, Types::RootStorageStatisticsType,
null: true,
- description: 'Aggregated storage statistics of the namespace. Only available for root namespaces',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(obj.id).find }
+ description: 'Aggregated storage statistics of the namespace. Only available for root namespaces'
field :projects, Types::ProjectType.connection_type, null: false,
description: 'Projects within this namespace',
resolver: ::Resolvers::NamespaceProjectsResolver
+
+ def root_storage_statistics
+ Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(object.id).find
+ end
end
end
diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb
index cc00feba2e6..13d9be49484 100644
--- a/app/graphql/types/notes/diff_position_type.rb
+++ b/app/graphql/types/notes/diff_position_type.rb
@@ -21,25 +21,43 @@ module Types
# Fields for text positions
field :old_line, GraphQL::INT_TYPE, null: true,
- description: 'Line on start SHA that was changed',
- resolve: -> (position, _args, _ctx) { position.old_line if position.on_text? }
+ description: 'Line on start SHA that was changed'
field :new_line, GraphQL::INT_TYPE, null: true,
- description: 'Line on HEAD SHA that was changed',
- resolve: -> (position, _args, _ctx) { position.new_line if position.on_text? }
+ description: 'Line on HEAD SHA that was changed'
# Fields for image positions
field :x, GraphQL::INT_TYPE, null: true,
- description: 'X position of the note',
- resolve: -> (position, _args, _ctx) { position.x if position.on_image? }
+ description: 'X position of the note'
field :y, GraphQL::INT_TYPE, null: true,
- description: 'Y position of the note',
- resolve: -> (position, _args, _ctx) { position.y if position.on_image? }
+ description: 'Y position of the note'
field :width, GraphQL::INT_TYPE, null: true,
- description: 'Total width of the image',
- resolve: -> (position, _args, _ctx) { position.width if position.on_image? }
+ description: 'Total width of the image'
field :height, GraphQL::INT_TYPE, null: true,
- description: 'Total height of the image',
- resolve: -> (position, _args, _ctx) { position.height if position.on_image? }
+ description: 'Total height of the image'
+
+ def old_line
+ object.old_line if object.on_text?
+ end
+
+ def new_line
+ object.new_line if object.on_text?
+ end
+
+ def x
+ object.x if object.on_image?
+ end
+
+ def y
+ object.y if object.on_image?
+ end
+
+ def width
+ object.width if object.on_image?
+ end
+
+ def height
+ object.height if object.on_image?
+ end
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index 5d41f0032bd..f4e05e19eca 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -16,13 +16,11 @@ module Types
field :project, Types::ProjectType,
null: true,
- description: 'Project associated with the note',
- resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, note.project_id).find }
+ description: 'Project associated with the note'
field :author, Types::UserType,
null: false,
- description: 'User who wrote this note',
- resolve: -> (note, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, note.author_id).find }
+ description: 'User who wrote this note'
field :system, GraphQL::BOOLEAN_TYPE,
null: false,
@@ -52,6 +50,14 @@ module Types
def system_note_icon_name
SystemNoteHelper.system_note_icon_name(object) if object.system?
end
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
+ end
+
+ def author
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
+ end
end
end
end
diff --git a/app/graphql/types/permission_types/merge_request.rb b/app/graphql/types/permission_types/merge_request.rb
index e9c89b0c92e..52c11fe5588 100644
--- a/app/graphql/types/permission_types/merge_request.rb
+++ b/app/graphql/types/permission_types/merge_request.rb
@@ -19,7 +19,9 @@ module Types
permission_field field_name, method: :"can_#{field_name}?", calls_gitaly: true
end
- permission_field :can_merge, calls_gitaly: true, resolve: -> (object, args, context) do
+ permission_field :can_merge, calls_gitaly: true
+
+ def can_merge
object.can_be_merged_by?(context[:current_user])
end
end
diff --git a/app/graphql/types/project_member_relation_enum.rb b/app/graphql/types/project_member_relation_enum.rb
new file mode 100644
index 00000000000..fbad23b956f
--- /dev/null
+++ b/app/graphql/types/project_member_relation_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class ProjectMemberRelationEnum < BaseEnum
+ graphql_name 'ProjectMemberRelation'
+ description 'Project member relation'
+
+ ::MembersFinder::RELATIONS.each do |member_relation|
+ value member_relation.to_s.upcase, value: member_relation, description: "#{member_relation.to_s.titleize} members"
+ end
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 5a436886117..a7d9548610e 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -67,33 +67,25 @@ module Types
description: 'E-mail address of the service desk.'
field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true,
- description: 'URL to avatar image file of the project',
- resolve: -> (project, args, ctx) do
- project.avatar_url(only_path: false)
- end
+ description: 'URL to avatar image file of the project'
%i[issues merge_requests wiki snippets].each do |feature|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true,
- description: "Indicates if #{feature.to_s.titleize.pluralize} are enabled for the current user",
- resolve: -> (project, args, ctx) do
- project.feature_available?(feature, ctx[:current_user])
- end
+ description: "Indicates if #{feature.to_s.titleize.pluralize} are enabled for the current user"
+
+ define_method "#{feature}_enabled" do
+ object.feature_available?(feature, context[:current_user])
+ end
end
field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true,
- description: 'Indicates if CI/CD pipeline jobs are enabled for the current user',
- resolve: -> (project, args, ctx) do
- project.feature_available?(:builds, ctx[:current_user])
- end
+ description: 'Indicates if CI/CD pipeline jobs are enabled for the current user'
field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true,
description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts'
field :open_issues_count, GraphQL::INT_TYPE, null: true,
- description: 'Number of open issues for the project',
- resolve: -> (project, args, ctx) do
- project.open_issues_count if project.feature_available?(:issues, ctx[:current_user])
- end
+ description: 'Number of open issues for the project'
field :import_status, GraphQL::STRING_TYPE, null: true,
description: 'Status of import background job of the project'
@@ -115,6 +107,8 @@ module Types
description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically'
field :suggestion_commit_message, GraphQL::STRING_TYPE, null: true,
description: 'The commit message used to apply merge request suggestions'
+ field :squash_read_only, GraphQL::BOOLEAN_TYPE, null: false, method: :squash_readonly?,
+ description: 'Indicates if squash readonly is enabled'
field :namespace, Types::NamespaceType, null: true,
description: 'Namespace of the project'
@@ -123,8 +117,7 @@ module Types
field :statistics, Types::ProjectStatisticsType,
null: true,
- description: 'Statistics of the project',
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find }
+ description: 'Statistics of the project'
field :repository, Types::RepositoryType, null: true,
description: 'Git repository of the project'
@@ -198,6 +191,11 @@ module Types
description: 'Build pipeline of the project',
resolver: Resolvers::ProjectPipelineResolver
+ field :ci_cd_settings,
+ Types::Ci::CiCdSettingType,
+ null: true,
+ description: 'CI/CD settings for the project'
+
field :sentry_detailed_error,
Types::ErrorTracking::SentryDetailedErrorType,
null: true,
@@ -238,8 +236,7 @@ module Types
field :jira_imports,
Types::JiraImportType.connection_type,
null: true,
- description: 'Jira imports into the project',
- resolver: Resolvers::Projects::JiraImportsResolver
+ description: 'Jira imports into the project'
field :services,
Types::Projects::ServiceType.connection_type,
@@ -296,6 +293,9 @@ module Types
description: 'Container repositories of the project',
resolver: Resolvers::ContainerRepositoriesResolver
+ field :container_repositories_count, GraphQL::INT_TYPE, null: false,
+ description: 'Number of container repositories in the project'
+
field :label,
Types::LabelType,
null: true,
@@ -311,6 +311,13 @@ module Types
description: 'Terraform states associated with the project',
resolver: Resolvers::Terraform::StatesResolver
+ field :pipeline_analytics, Types::Ci::AnalyticsType, null: true,
+ description: 'Pipeline analytics',
+ resolver: Resolvers::ProjectPipelineStatisticsResolver
+
+ field :total_pipeline_duration, GraphQL::INT_TYPE, null: true,
+ description: 'Total pipeline duration for all of the pipelines in a project'
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
@@ -335,6 +342,30 @@ module Types
.execute
end
+ def avatar_url
+ object.avatar_url(only_path: false)
+ end
+
+ def jobs_enabled
+ object.feature_available?(:builds, context[:current_user])
+ end
+
+ def open_issues_count
+ object.open_issues_count if object.feature_available?(:issues, context[:current_user])
+ end
+
+ def statistics
+ Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(object.id).find
+ end
+
+ def container_repositories_count
+ project.container_repositories.size
+ end
+
+ def total_pipeline_duration
+ object.all_pipelines.total_duration
+ end
+
private
def project
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index d194b0979b3..05bb371088c 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -24,7 +24,6 @@ module Types
field :current_user, Types::UserType,
null: true,
- resolve: -> (_obj, _args, context) { context[:current_user] },
description: "Get information about current user"
field :namespace, Types::NamespaceType,
@@ -92,6 +91,11 @@ module Types
description: 'Get runner setup instructions',
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
+
def design_management
DesignManagementObject.new(nil)
end
@@ -116,6 +120,10 @@ module Types
id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
+
+ def current_user
+ context[:current_user]
+ end
end
end
diff --git a/app/graphql/types/snippets/blob_viewer_type.rb b/app/graphql/types/snippets/blob_viewer_type.rb
index 50d0b0522d6..a2ffa144066 100644
--- a/app/graphql/types/snippets/blob_viewer_type.rb
+++ b/app/graphql/types/snippets/blob_viewer_type.rb
@@ -17,14 +17,12 @@ module Types
field :collapsed, GraphQL::BOOLEAN_TYPE,
description: 'Shows whether the blob should be displayed collapsed',
method: :collapsed?,
- null: false,
- resolve: -> (viewer, _args, _ctx) { !!viewer&.collapsed? }
+ null: false
field :too_large, GraphQL::BOOLEAN_TYPE,
description: 'Shows whether the blob too large to be displayed',
method: :too_large?,
- null: false,
- resolve: -> (viewer, _args, _ctx) { !!viewer&.too_large? }
+ null: false
field :render_error, GraphQL::STRING_TYPE,
description: 'Error rendering the blob content',
@@ -38,6 +36,14 @@ module Types
field :loading_partial_name, GraphQL::STRING_TYPE,
description: 'Loading partial name',
null: false
+
+ def collapsed
+ !!object&.collapsed?
+ end
+
+ def too_large
+ !!object&.too_large?
+ end
end
end
end
diff --git a/app/graphql/types/sort_enum.rb b/app/graphql/types/sort_enum.rb
index d0a6eecb672..c3a76330fe9 100644
--- a/app/graphql/types/sort_enum.rb
+++ b/app/graphql/types/sort_enum.rb
@@ -7,10 +7,10 @@ module Types
# Deprecated, as we prefer uppercase enums
# https://gitlab.com/groups/gitlab-org/-/epics/1838
- value 'updated_desc', 'Updated at descending order', deprecated: { reason: 'Use UPDATED_DESC', milestone: '13.5' }
- value 'updated_asc', 'Updated at ascending order', deprecated: { reason: 'Use UPDATED_ASC', milestone: '13.5' }
- value 'created_desc', 'Created at descending order', deprecated: { reason: 'Use CREATED_DESC', milestone: '13.5' }
- value 'created_asc', 'Created at ascending order', deprecated: { reason: 'Use CREATED_ASC', milestone: '13.5' }
+ value 'updated_desc', 'Updated at descending order', value: :updated_desc, 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
value 'UPDATED_ASC', 'Updated at ascending order', value: :updated_asc
diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb
index 05b6d130f19..d97e673bf31 100644
--- a/app/graphql/types/terraform/state_type.rb
+++ b/app/graphql/types/terraform/state_type.rb
@@ -19,9 +19,7 @@ module Types
field :locked_by_user, Types::UserType,
null: true,
- authorize: :read_user,
- description: 'The user currently holding a lock on the Terraform state',
- resolve: -> (state, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, state.locked_by_user_id).find }
+ description: 'The user currently holding a lock on the Terraform state'
field :locked_at, Types::TimeType,
null: true,
@@ -39,6 +37,10 @@ module Types
field :updated_at, Types::TimeType,
null: false,
description: 'Timestamp the Terraform state was updated'
+
+ def locked_by_user
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.locked_by_user_id).find
+ end
end
end
end
diff --git a/app/graphql/types/terraform/state_version_type.rb b/app/graphql/types/terraform/state_version_type.rb
index b1fbe42ecaf..a3af5c876ca 100644
--- a/app/graphql/types/terraform/state_version_type.rb
+++ b/app/graphql/types/terraform/state_version_type.rb
@@ -3,6 +3,8 @@
module Types
module Terraform
class StateVersionType < BaseObject
+ include ::API::Helpers::RelatedResourcesHelpers
+
graphql_name 'TerraformStateVersion'
authorize :read_terraform_state
@@ -13,15 +15,20 @@ module Types
field :created_by_user, Types::UserType,
null: true,
- authorize: :read_user,
- description: 'The user that created this version',
- resolve: -> (version, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, version.created_by_user_id).find }
+ description: 'The user that created this version'
+
+ field :download_path, GraphQL::STRING_TYPE,
+ null: true,
+ description: "URL for downloading the version's JSON file"
field :job, Types::Ci::JobType,
null: true,
- authorize: :read_build,
- description: 'The job that created this version',
- resolve: -> (version, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Build, version.ci_build_id).find }
+ description: 'The job that created this version'
+
+ field :serial, GraphQL::INT_TYPE,
+ null: true,
+ description: 'Serial number of the version',
+ method: :version
field :created_at, Types::TimeType,
null: false,
@@ -30,6 +37,22 @@ module Types
field :updated_at, Types::TimeType,
null: false,
description: 'Timestamp the version was updated'
+
+ def created_by_user
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.created_by_user_id).find
+ end
+
+ def download_path
+ expose_path api_v4_projects_terraform_state_versions_path(
+ id: object.project_id,
+ name: object.terraform_state.name,
+ serial: object.version
+ )
+ end
+
+ def job
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Build, object.ci_build_id).find
+ end
end
end
end
diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb
index 4f21da3d897..3694980ef93 100644
--- a/app/graphql/types/todo_type.rb
+++ b/app/graphql/types/todo_type.rb
@@ -16,19 +16,16 @@ module Types
field :project, Types::ProjectType,
description: 'The project this todo is associated with',
null: true,
- authorize: :read_project,
- resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, todo.project_id).find }
+ authorize: :read_project
field :group, Types::GroupType,
description: 'Group this todo is associated with',
null: true,
- authorize: :read_group,
- resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, todo.group_id).find }
+ authorize: :read_group
field :author, Types::UserType,
description: 'The author of this todo',
- null: false,
- resolve: -> (todo, args, context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, todo.author_id).find }
+ null: false
field :action, Types::TodoActionEnum,
description: 'Action of the todo',
@@ -50,5 +47,17 @@ module Types
field :created_at, Types::TimeType,
description: 'Timestamp this todo was created',
null: false
+
+ def project
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, object.project_id).find
+ end
+
+ def group
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.group_id).find
+ end
+
+ def author
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
+ end
end
end
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
index cc6bf7b4f00..a7b90d2533b 100644
--- a/app/graphql/types/tree/blob_type.rb
+++ b/app/graphql/types/tree/blob_type.rb
@@ -15,13 +15,14 @@ module Types
field :web_path, GraphQL::STRING_TYPE, null: true,
description: 'Web path of the blob'
field :lfs_oid, GraphQL::STRING_TYPE, null: true,
- description: 'LFS ID of the blob',
- resolve: -> (blob, args, ctx) do
- Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find
- end
+ description: 'LFS ID of the blob'
field :mode, GraphQL::STRING_TYPE, null: true,
description: 'Blob mode in numeric format'
- # rubocop: enable Graphql/AuthorizeTypes
+
+ def lfs_oid
+ Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(object.repository, object.id).find
+ end
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb
index b9fb6b28e71..fecd6c0f309 100644
--- a/app/graphql/types/tree/tree_type.rb
+++ b/app/graphql/types/tree/tree_type.rb
@@ -8,27 +8,32 @@ module Types
# Complexity 10 as it triggers a Gitaly call on each render
field :last_commit, Types::CommitType,
- null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver,
- description: 'Last commit for the tree'
+ null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver,
+ description: 'Last commit for the tree'
field :trees, Types::Tree::TreeEntryType.connection_type, null: false,
- description: 'Trees of the tree',
- resolve: -> (obj, args, ctx) do
- Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository)
- end
+ description: 'Trees of the tree'
field :submodules, Types::Tree::SubmoduleType.connection_type, null: false,
description: 'Sub-modules of the tree',
- calls_gitaly: true, resolve: -> (obj, args, ctx) do
- Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(obj.submodules, obj)
- end
+ calls_gitaly: true
field :blobs, Types::Tree::BlobType.connection_type, null: false,
description: 'Blobs of the tree',
- calls_gitaly: true, resolve: -> (obj, args, ctx) do
- Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository)
- end
- # rubocop: enable Graphql/AuthorizeTypes
+ calls_gitaly: true
+
+ def trees
+ Gitlab::Graphql::Representation::TreeEntry.decorate(object.trees, object.repository)
+ end
+
+ def submodules
+ Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(object.submodules, object)
+ end
+
+ def blobs
+ Gitlab::Graphql::Representation::TreeEntry.decorate(object.blobs, object.repository)
+ end
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index 783a0d8425a..93503268319 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -19,7 +19,10 @@ module Types
field :state, Types::UserStateEnum, null: false,
description: 'State of the user'
field :email, GraphQL::STRING_TYPE, null: true,
- description: 'User email', method: :public_email
+ description: 'User email', method: :public_email,
+ deprecated: { reason: 'Use public_email', milestone: '13.7' }
+ field :public_email, GraphQL::STRING_TYPE, null: true,
+ description: "User's public email"
field :avatar_url, GraphQL::STRING_TYPE, null: true,
description: "URL of the user's avatar"
field :web_url, GraphQL::STRING_TYPE, null: false,
@@ -37,19 +40,24 @@ module Types
feature_flag: :user_group_counts
field :status, Types::UserStatusType, null: true,
description: 'User status'
+ field :location, ::GraphQL::STRING_TYPE, null: true,
+ description: 'The location of the user.'
field :project_memberships, Types::ProjectMemberType.connection_type, null: true,
description: 'Project memberships of the user'
field :starred_projects, Types::ProjectType.connection_type, null: true,
description: 'Projects starred by the user',
resolver: Resolvers::UserStarredProjectsResolver
- # Merge request field: MRs can be either authored or assigned:
+ # 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'
field :assigned_merge_requests,
resolver: Resolvers::AssignedMergeRequestsResolver,
description: 'Merge Requests assigned to the user'
+ field :review_requested_merge_requests,
+ resolver: Resolvers::ReviewRequestedMergeRequestsResolver,
+ description: 'Merge Requests assigned to the user for review'
field :snippets,
Types::SnippetType.connection_type,
diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb
new file mode 100644
index 00000000000..cd520a75b44
--- /dev/null
+++ b/app/helpers/admin/user_actions_helper.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Admin
+ module UserActionsHelper
+ def admin_actions(user)
+ return [] if user.internal?
+
+ @actions ||= ['edit']
+
+ return @actions if user == current_user
+
+ @user ||= user
+
+ blocked_actions
+ deactivate_actions
+ unlock_actions
+ delete_actions
+
+ @actions
+ end
+
+ private
+
+ def blocked_actions
+ if @user.ldap_blocked?
+ @actions << 'ldap'
+ elsif @user.blocked? && @user.blocked_pending_approval?
+ @actions << 'approve'
+ @actions << 'reject'
+ elsif @user.blocked?
+ @actions << 'unblock'
+ else
+ @actions << 'block'
+ end
+ end
+
+ def deactivate_actions
+ if @user.can_be_deactivated?
+ @actions << 'deactivate'
+ elsif @user.deactivated?
+ @actions << 'activate'
+ end
+ end
+
+ def unlock_actions
+ @actions << 'unlock' if @user.access_locked?
+ end
+
+ def delete_actions
+ return unless can?(current_user, :destroy_user, @user) && !@user.blocked_pending_approval? && @user.can_be_removed?
+
+ @actions << 'delete'
+ @actions << 'delete_with_contributions'
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 2a6b00c0bd8..512ba7e2a66 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -361,9 +361,13 @@ module ApplicationHelper
}
end
- def add_page_specific_style(path)
+ def add_page_specific_style(path, defer: true)
content_for :page_specific_styles do
- stylesheet_link_tag_defer path
+ if defer
+ stylesheet_link_tag_defer path
+ else
+ stylesheet_link_tag path
+ end
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 512649b3008..7866e3e3d9f 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -49,12 +49,12 @@ module ApplicationSettingsHelper
all_protocols_enabled? || Gitlab::CurrentSettings.enabled_git_access_protocol == 'http'
end
- def enabled_project_button(project, protocol)
+ def enabled_protocol_button(container, protocol)
case protocol
when 'ssh'
- ssh_clone_button(project, append_link: false)
+ ssh_clone_button(container, append_link: false)
else
- http_clone_button(project, append_link: false)
+ http_clone_button(container, append_link: false)
end
end
@@ -198,6 +198,7 @@ module ApplicationSettingsHelper
:default_project_visibility,
:default_projects_limit,
:default_snippet_visibility,
+ :disable_feed_token,
:disabled_oauth_sign_in_sources,
:domain_denylist,
:domain_denylist_enabled,
@@ -254,6 +255,9 @@ module ApplicationSettingsHelper
:password_authentication_enabled_for_git,
:performance_bar_allowed_group_path,
:performance_bar_enabled,
+ :personal_access_token_prefix,
+ :kroki_enabled,
+ :kroki_url,
:plantuml_enabled,
:plantuml_url,
:polling_interval_multiplier,
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index cc43ea85a11..0b79d4c36a1 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -113,6 +113,10 @@ module AuthHelper
end
end
+ def experiment_enabled_button_based_providers
+ enabled_button_based_providers & %w(google_oauth2 github).freeze
+ end
+
def button_based_providers_enabled?
enabled_button_based_providers.any?
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 981b5e4d92b..0c5823894c5 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -1,10 +1,6 @@
# frozen_string_literal: true
module BlobHelper
- def no_highlight_files
- %w(credits changelog news copying copyright license authors)
- end
-
def edit_blob_path(project = @project, ref = @ref, path = @path, options = {})
project_edit_blob_path(project,
tree_join(ref, path),
@@ -246,7 +242,7 @@ module BlobHelper
def copy_blob_source_button(blob)
return unless blob.rendered_as_text?(ignore_errors: false)
- clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: _("Copy file contents"))
+ clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}'] > pre", class: "btn btn-sm js-copy-blob-source-btn", title: _("Copy file contents"))
end
def open_raw_blob_button(blob)
@@ -332,8 +328,9 @@ module BlobHelper
end
def readable_blob(options, path, project, ref)
- blob = options.delete(:blob)
- blob ||= project.repository.blob_at(ref, path) rescue nil
+ blob = options.fetch(:blob) do
+ project.repository.blob_at(ref, path) rescue nil
+ end
blob if blob&.readable_text?
end
@@ -382,8 +379,7 @@ module BlobHelper
end
def show_suggest_pipeline_creation_celebration?
- Feature.enabled?(:suggest_pipeline, default_enabled: true) &&
- @blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] &&
+ @blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] &&
@blob.auxiliary_viewer&.valid?(project: @project, sha: @commit.sha, user: current_user) &&
@project.uses_default_ci_config? &&
cookies[suggest_pipeline_commit_cookie_name].present?
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index c999d1f94ad..ea24f469ffa 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -58,10 +58,10 @@ module ButtonHelper
end
end
- def http_clone_button(project, append_link: true)
+ def http_clone_button(container, append_link: true)
protocol = gitlab_config.protocol.upcase
dropdown_description = http_dropdown_description(protocol)
- append_url = project.http_url_to_repo if append_link
+ append_url = container.http_url_to_repo if append_link
dropdown_item_with_description(protocol, dropdown_description, href: append_url, data: { clone_type: 'http' })
end
@@ -74,13 +74,13 @@ module ButtonHelper
end
end
- def ssh_clone_button(project, append_link: true)
+ def ssh_clone_button(container, append_link: true)
if Gitlab::CurrentSettings.user_show_add_ssh_key_message? &&
current_user.try(:require_ssh_key?)
- dropdown_description = _("You won't be able to pull or push project code via SSH until you add an SSH key to your profile")
+ dropdown_description = s_("MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile")
end
- append_url = project.ssh_url_to_repo if append_link
+ append_url = container.ssh_url_to_repo if append_link
dropdown_item_with_description('SSH', dropdown_description, href: append_url, data: { clone_type: 'ssh' })
end
diff --git a/app/helpers/ci/pipeline_schedules_helper.rb b/app/helpers/ci/pipeline_schedules_helper.rb
deleted file mode 100644
index 20e5c90a60e..00000000000
--- a/app/helpers/ci/pipeline_schedules_helper.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- module PipelineSchedulesHelper
- def timezone_data
- ActiveSupport::TimeZone.all.map do |timezone|
- {
- name: timezone.name,
- offset: timezone.now.utc_offset,
- identifier: timezone.tzinfo.identifier
- }
- end
- end
- end
-end
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 432aad663e4..ba5d4e8c65a 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -8,14 +8,14 @@ module Ci
status = runner.status
case status
when :not_connected
- content_tag(:span, title: "New runner. Has not connected yet") do
+ content_tag(:span, title: _("New runner. Has not connected yet")) do
sprite_icon("warning-solid", size: 24, css_class: "gl-vertical-align-bottom!")
end
when :online, :offline, :paused
- content_tag :i, nil,
- class: "fa fa-circle runner-status-#{status}",
- title: "Runner is #{status}, last contact was #{time_ago_in_words(runner.contacted_at)} ago"
+ 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) }
end
end
@@ -49,6 +49,14 @@ module Ci
parent_shared_runners_availability: group.parent&.shared_runners_setting
}
end
+
+ def toggle_shared_runners_settings_data(project)
+ {
+ is_enabled: "#{project.shared_runners_enabled?}",
+ is_disabled_and_unoverridable: "#{project.group&.shared_runners_setting == 'disabled_and_unoverridable'}",
+ update_path: toggle_shared_runners_project_runners_path(project)
+ }
+ end
end
end
diff --git a/app/helpers/container_registry_helper.rb b/app/helpers/container_registry_helper.rb
index 9a5d84a90dd..0efc8c50d58 100644
--- a/app/helpers/container_registry_helper.rb
+++ b/app/helpers/container_registry_helper.rb
@@ -5,4 +5,8 @@ module ContainerRegistryHelper
Feature.enabled?(:container_registry_expiration_policies_throttling) &&
ContainerRegistry::Client.supports_tag_delete?
end
+
+ def container_repository_gid_prefix
+ "gid://#{GlobalID.app}/#{ContainerRepository.name}/"
+ end
end
diff --git a/app/helpers/defer_script_tag_helper.rb b/app/helpers/defer_script_tag_helper.rb
deleted file mode 100644
index be927c67aaa..00000000000
--- a/app/helpers/defer_script_tag_helper.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-module DeferScriptTagHelper
- # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading.
- # PLEASE NOTE: `defer` is also critical so that we don't run JavaScript entrypoints before the DOM is ready.
- # Please see https://gitlab.com/groups/gitlab-org/-/epics/4538#note_432159769.
- def javascript_include_tag(*sources)
- super(*sources, defer: true)
- end
-end
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index d6d06434590..69a2efebb1f 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -203,14 +203,6 @@ module DiffHelper
set_secure_cookie(:diff_view, params.delete(:view), type: CookiesHelper::COOKIE_TYPE_PERMANENT) if params[:view].present?
end
- def unified_diff_lines_view_type(project)
- if Feature.enabled?(:unified_diff_lines, project, default_enabled: true)
- 'inline'
- else
- diff_view
- end
- end
-
private
def diff_btn(title, name, selected)
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index e10e9a83b05..45f5281b515 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -51,7 +51,7 @@ module DropdownsHelper
default_label = data_attr[:default_label]
content_tag(:button, disabled: options[:disabled], class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do
output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}")
- output << icon('chevron-down')
+ output << sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
output.html_safe
end
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index c4487ae8e4a..491d2731e91 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -52,6 +52,8 @@ module EnvironmentHelper
s_('Deployment|failed')
when 'canceled'
s_('Deployment|canceled')
+ when 'skipped'
+ s_('Deployment|skipped')
end
klass = "ci-status ci-#{status.dasherize}"
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index f40755b9439..e6603237676 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -256,7 +256,7 @@ module EventsHelper
end
else
content_tag :div, class: 'system-note-image user-avatar' do
- author_avatar(event, size: 40)
+ author_avatar(event, size: 32)
end
end
end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 8a8d708b0b2..d0276c91316 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -55,7 +55,7 @@ module FormHelper
dropdown_data
end
- def reviewers_dropdown_options(issuable_type)
+ def reviewers_dropdown_options(issuable_type, iid = nil, target_branch = nil)
dropdown_data = {
toggle_class: 'js-reviewer-search js-multiselect js-save-user-data',
title: 'Request review from',
@@ -78,6 +78,14 @@ module FormHelper
}
}
+ if iid
+ dropdown_data[:data][:iid] = iid
+ end
+
+ if target_branch
+ dropdown_data[:data][:target_branch] = target_branch
+ end
+
if merge_request_supports_multiple_reviewers?
dropdown_data = multiple_reviewers_dropdown_options(dropdown_data)
end
diff --git a/app/helpers/gitlab_script_tag_helper.rb b/app/helpers/gitlab_script_tag_helper.rb
new file mode 100644
index 00000000000..467f3f7305b
--- /dev/null
+++ b/app/helpers/gitlab_script_tag_helper.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module GitlabScriptTagHelper
+ # Override the default ActionView `javascript_include_tag` helper to support page specific deferred loading.
+ # PLEASE NOTE: `defer` is also critical so that we don't run JavaScript entrypoints before the DOM is ready.
+ # Please see https://gitlab.com/groups/gitlab-org/-/epics/4538#note_432159769.
+ # The helper also makes sure the `nonce` attribute is included in every script when the content security
+ # policy is enabled.
+ def javascript_include_tag(*sources)
+ super(*sources, defer: true, nonce: true)
+ end
+
+ # The helper makes sure the `nonce` attribute is included in every script when the content security
+ # policy is enabled.
+ def javascript_tag(content_or_options_with_block = nil, html_options = {})
+ if content_or_options_with_block.is_a?(Hash)
+ content_or_options_with_block[:nonce] = true
+ else
+ html_options[:nonce] = true
+ end
+
+ super
+ end
+end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index ee90585112b..adc9d85a384 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -26,7 +26,8 @@ module Groups::GroupMembersHelper
{
members: members_data_json(group, members),
member_path: group_group_member_path(group, ':id'),
- group_id: group.id
+ group_id: group.id,
+ can_manage_members: can?(current_user, :admin_group_member, group).to_s
}
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 29ead76a607..e8eb6a5d417 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -21,7 +21,6 @@ module GroupsHelper
integrations#edit
ldap_group_links#index
hooks#index
- audit_events#index
pipeline_quota#index
]
end
@@ -189,6 +188,10 @@ module GroupsHelper
params.key?(:purchased_quantity) && params[:purchased_quantity].to_i > 0
end
+ def project_list_sort_by
+ @group_projects_sort || @sort || params[:sort] || sort_value_recently_created
+ end
+
private
def just_created?
diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb
index dc6164ee898..096a3f2269b 100644
--- a/app/helpers/icons_helper.rb
+++ b/app/helpers/icons_helper.rb
@@ -4,25 +4,9 @@ require 'json'
module IconsHelper
extend self
- include FontAwesome::Rails::IconHelper
DEFAULT_ICON_SIZE = 16
- # Creates an icon tag given icon name(s) and possible icon modifiers.
- #
- # Right now this method simply delegates directly to `fa_icon` from the
- # font-awesome-rails gem, but should we ever use a different icon pack in the
- # future we won't have to change hundreds of method calls.
- def icon(names, options = {})
- if (options.keys & %w[aria-hidden aria-label data-hidden]).empty?
- # Add 'aria-hidden' and 'data-hidden' if they are not set in options.
- options['aria-hidden'] = true
- options['data-hidden'] = true
- end
-
- options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options)
- end
-
def custom_icon(icon_name, size: DEFAULT_ICON_SIZE)
memoized_icon("#{icon_name}_#{size}") do
# We can't simply do the below, because there are some .erb SVGs.
@@ -95,9 +79,9 @@ module IconsHelper
def boolean_to_icon(value)
if value
- sprite_icon('check', css_class: 'cgreen')
+ sprite_icon('check', css_class: 'gl-text-green-500')
else
- sprite_icon('power', css_class: 'clgray')
+ sprite_icon('power', css_class: 'gl-text-gray-500')
end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 77ced17bc22..15842dec3dd 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -61,16 +61,6 @@ module IssuablesHelper
end
end
- def issuable_json_path(issuable)
- project = issuable.project
-
- if issuable.is_a?(MergeRequest)
- project_merge_request_path(project, issuable.iid, :json)
- else
- project_issue_path(project, issuable.iid, :json)
- end
- end
-
def serialize_issuable(issuable, opts = {})
serializer_klass = case issuable
when Issue
@@ -174,30 +164,26 @@ module IssuablesHelper
h(title || default_label)
end
- def to_url_reference(issuable)
- case issuable
- when Issue
- link_to issuable.to_reference, issue_url(issuable)
- when MergeRequest
- link_to issuable.to_reference, merge_request_url(issuable)
- else
- issuable.to_reference
- end
+ def issuable_meta_author_status(author)
+ return "" unless show_status_emoji?(author&.status) && status = user_status(author)
+
+ "#{status}".html_safe
end
- def issuable_meta(issuable, project, text)
+ def issuable_meta(issuable, project)
output = []
output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
+ if issuable.is_a?(Issue) && issuable.service_desk_reply_to
+ output << "#{html_escape(issuable.service_desk_reply_to)} via "
+ end
+
output << content_tag(:strong) do
author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline")
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none")
author_output << issuable_meta_author_slot(issuable.author, css_class: 'ml-1')
-
- if status = user_status(issuable.author)
- author_output << "#{status}".html_safe
- end
+ author_output << issuable_meta_author_status(issuable.author)
author_output
end
@@ -336,42 +322,10 @@ module IssuablesHelper
issuable_path(issuable, close_reopen_params(issuable, :reopen))
end
- def close_reopen_issuable_path(issuable, should_inverse = false)
- issuable.closed? ^ should_inverse ? reopen_issuable_path(issuable) : close_issuable_path(issuable)
- end
-
- def toggle_draft_issuable_path(issuable)
- wip_event = issuable.work_in_progress? ? 'unwip' : 'wip'
-
- issuable_path(issuable, { merge_request: { wip_event: wip_event } })
- end
-
def issuable_path(issuable, *options)
polymorphic_path(issuable, *options)
end
- def issuable_url(issuable, *options)
- case issuable
- when Issue
- issue_url(issuable, *options)
- when MergeRequest
- merge_request_url(issuable, *options)
- end
- end
-
- def issuable_button_visibility(issuable, closed)
- return 'hidden' if issuable_button_hidden?(issuable, closed)
- end
-
- def issuable_button_hidden?(issuable, closed)
- case issuable
- when Issue
- issue_button_hidden?(issuable, closed)
- when MergeRequest
- merge_request_button_hidden?(issuable, closed)
- end
- end
-
def issuable_author_is_current_user(issuable)
issuable.author == current_user
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index dee009cd3ab..0a9965496b8 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -32,14 +32,6 @@ module IssuesHelper
end
end
- def issue_button_visibility(issue, closed)
- return 'hidden' if issue_button_hidden?(issue, closed)
- end
-
- def issue_button_hidden?(issue, closed)
- issue.closed? == closed || (!closed && issue.discussion_locked)
- end
-
def confidential_icon(issue)
sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential?
end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index ed8931fe0f2..25d56ffca2c 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -126,16 +126,7 @@ module MarkupHelper
text = wiki_page.content
return '' unless text.present?
- context.merge!(
- pipeline: :wiki,
- project: @project,
- wiki: @wiki,
- repository: @wiki.repository,
- page_slug: wiki_page.slug,
- issuable_state_filter_enabled: true
- )
-
- html = markup_unsafe(wiki_page.path, text, context)
+ html = markup_unsafe(wiki_page.path, text, render_wiki_content_context(@wiki, wiki_page, context))
prepare_for_rendering(html, context)
end
@@ -182,6 +173,20 @@ module MarkupHelper
private
+ def render_wiki_content_context(wiki, wiki_page, context)
+ context.merge(
+ pipeline: :wiki,
+ wiki: wiki,
+ repository: wiki.repository,
+ page_slug: wiki_page.slug,
+ issuable_state_filter_enabled: true
+ ).merge(render_wiki_content_context_container(wiki))
+ end
+
+ def render_wiki_content_context_container(wiki)
+ { project: wiki.container }
+ end
+
# Return +text+, truncated to +max_chars+ characters, excluding any HTML
# tags.
def truncate_visible(text, max_chars)
@@ -311,3 +316,5 @@ module MarkupHelper
extend self
end
+
+MarkupHelper.prepend_if_ee('EE::MarkupHelper')
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 9cb7edbaeb6..37e701c1c2b 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -39,19 +39,6 @@ module MergeRequestsHelper
end
end
- def ci_build_details_path(merge_request)
- build_url = merge_request.source_project.ci_service.build_page(merge_request.diff_head_sha, merge_request.source_branch)
- return unless build_url
-
- parsed_url = URI.parse(build_url)
-
- unless parsed_url.userinfo.blank?
- parsed_url.userinfo = ''
- end
-
- parsed_url.to_s
- end
-
def merge_path_description(merge_request, separator)
if merge_request.for_fork?
"Project:Branches: #{@merge_request.source_project_path}:#{@merge_request.source_branch} #{separator} #{@merge_request.target_project.full_path}:#{@merge_request.target_branch}"
@@ -96,7 +83,7 @@ module MergeRequestsHelper
end
def merge_request_button_hidden?(merge_request, closed)
- merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_without_fork?
+ merge_request.closed? == closed || (merge_request.merged? == closed && !merge_request.closed?) || merge_request.closed_or_merged_without_fork?
end
def merge_request_version_path(project, merge_request, merge_request_diff, start_sha = nil)
@@ -166,6 +153,12 @@ module MergeRequestsHelper
current_user.fork_of(project)
end
end
+
+ def toggle_draft_merge_request_path(issuable)
+ wip_event = issuable.work_in_progress? ? 'unwip' : 'wip'
+
+ issuable_path(issuable, { merge_request: { wip_event: wip_event } })
+ end
end
MergeRequestsHelper.prepend_if_ee('EE::MergeRequestsHelper')
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 61fcda6a504..2b68d953431 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -106,7 +106,8 @@ module NotificationsHelper
when :success_pipeline
s_('NotificationEvent|Successful pipeline')
else
- s_(event.to_s.humanize)
+ event_name = "NotificationEvent|#{event.to_s.humanize}"
+ s_(event_name)
end
end
diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb
index fb68029928c..db7527d9d58 100644
--- a/app/helpers/notify_helper.rb
+++ b/app/helpers/notify_helper.rb
@@ -5,7 +5,7 @@ module NotifyHelper
link_to(entity.to_reference, merge_request_url(entity, *args))
end
- def issue_reference_link(entity, *args)
- link_to(entity.to_reference, issue_url(entity, *args))
+ def issue_reference_link(entity, *args, full: false)
+ link_to(entity.to_reference(full: full), issue_url(entity, *args))
end
end
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
index 8105fce10cf..6d721776f0d 100644
--- a/app/helpers/operations_helper.rb
+++ b/app/helpers/operations_helper.rb
@@ -9,24 +9,14 @@ module OperationsHelper
end
end
- def alerts_service
- strong_memoize(:alerts_service) do
- @project.find_or_initialize_service(::AlertsService.to_param)
- end
- end
-
def alerts_settings_data(disabled: false)
{
'prometheus_activated' => prometheus_service.manual_configuration?.to_s,
- 'activated' => alerts_service.activated?.to_s,
'prometheus_form_path' => scoped_integration_path(prometheus_service),
- 'form_path' => scoped_integration_path(alerts_service),
'prometheus_reset_key_path' => reset_alerting_token_project_settings_operations_path(@project),
'prometheus_authorization_key' => @project.alerting_setting&.token,
'prometheus_api_url' => prometheus_service.api_url,
- 'authorization_key' => alerts_service.token,
'prometheus_url' => notify_project_prometheus_alerts_url(@project, format: :json),
- 'url' => alerts_service.url,
'alerts_setup_url' => help_page_path('operations/incident_management/alert_integrations.md', anchor: 'generic-http-endpoint'),
'alerts_usage_url' => project_alert_management_index_path(@project),
'disabled' => disabled.to_s,
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index 04a3b915493..87187e97df4 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -37,10 +37,4 @@ module ProfilesHelper
def user_status_set_to_busy?(status)
status&.availability == availability_values[:busy]
end
-
- def show_status_emoji?(status)
- return false unless status
-
- status.message.present? || status.emoji != UserStatus::DEFAULT_EMOJI
- end
end
diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb
index c6ad6bfac01..997551d9659 100644
--- a/app/helpers/projects/alert_management_helper.rb
+++ b/app/helpers/projects/alert_management_helper.rb
@@ -28,7 +28,7 @@ module Projects::AlertManagementHelper
def alert_management_enabled?(project)
!!(
- project.alerts_service_activated? ||
+ project.alert_management_alerts.any? ||
project.prometheus_service_active? ||
AlertManagement::HttpIntegrationsFinder.new(project, active: true).execute.any?
)
diff --git a/app/helpers/projects/terraform_helper.rb b/app/helpers/projects/terraform_helper.rb
index b286bc4d7a5..621d97ffb69 100644
--- a/app/helpers/projects/terraform_helper.rb
+++ b/app/helpers/projects/terraform_helper.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
module Projects::TerraformHelper
- def js_terraform_list_data(project)
+ def js_terraform_list_data(current_user, project)
{
empty_state_image: image_path('illustrations/empty-state/empty-serverless-lg.svg'),
- project_path: project.full_path
+ project_path: project.full_path,
+ terraform_admin: current_user&.can?(:admin_terraform_state, project)
}
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index f25b229d198..80206654cd1 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -463,11 +463,12 @@ module ProjectsHelper
issues: :read_issue,
project_members: :read_project_member,
wiki: :read_wiki,
- feature_flags: :read_feature_flag
+ feature_flags: :read_feature_flag,
+ analytics: :read_analytics
}
end
- def can_view_operations_tab?(current_user, project)
+ def view_operations_tab_ability
[
:metrics_dashboard,
:read_alert_management_alert,
@@ -477,7 +478,13 @@ module ProjectsHelper
:read_cluster,
:read_feature_flag,
:read_terraform_state
- ].any? do |ability|
+ ]
+ end
+
+ def can_view_operations_tab?(current_user, project)
+ return false unless project.feature_available?(:operations, current_user)
+
+ view_operations_tab_ability.any? do |ability|
can?(current_user, ability, project)
end
end
@@ -606,6 +613,7 @@ module ProjectsHelper
def project_permissions_settings(project)
feature = project.project_feature
+
{
packagesEnabled: !!project.packages_enabled,
visibilityLevel: project.visibility_level,
@@ -618,11 +626,14 @@ module ProjectsHelper
wikiAccessLevel: feature.wiki_access_level,
snippetsAccessLevel: feature.snippets_access_level,
pagesAccessLevel: feature.pages_access_level,
+ analyticsAccessLevel: feature.analytics_access_level,
containerRegistryEnabled: !!project.container_registry_enabled,
lfsEnabled: !!project.lfs_enabled,
emailsDisabled: project.emails_disabled?,
metricsDashboardAccessLevel: feature.metrics_dashboard_access_level,
- showDefaultAwardEmojis: project.show_default_award_emojis?
+ operationsAccessLevel: feature.operations_access_level,
+ showDefaultAwardEmojis: project.show_default_award_emojis?,
+ allowEditingCommitMessages: project.allow_editing_commit_messages?
}
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index de1e0e4e05e..bdc86043ddc 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -31,7 +31,7 @@ module SearchHelper
[
resources_results,
generic_results
- ].flatten.uniq do |item|
+ ].flatten do |item|
item[:label]
end
end
@@ -370,7 +370,7 @@ module SearchHelper
def highlight_and_truncate_issuable(issuable, search_term, _search_highlight)
return unless issuable.description.present?
- simple_search_highlight_and_truncate(issuable.description, search_term, highlighter: '<span class="gl-text-black-normal gl-font-weight-bold">\1</span>')
+ simple_search_highlight_and_truncate(issuable.description, search_term, highlighter: '<span class="gl-text-gray-900 gl-font-weight-bold">\1</span>')
end
def show_user_search_tab?
diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb
index 96eb14be4b4..3516000e296 100644
--- a/app/helpers/services_helper.rb
+++ b/app/helpers/services_helper.rb
@@ -75,7 +75,15 @@ module ServicesHelper
end
end
- def integration_form_data(integration)
+ def scoped_reset_integration_path(integration, group: nil)
+ if group.present?
+ reset_group_settings_integration_path(group, integration)
+ else
+ reset_admin_application_settings_integration_path(integration)
+ end
+ end
+
+ def integration_form_data(integration, group: nil)
{
id: integration.id,
show_active: integration.show_active_box?.to_s,
@@ -94,7 +102,7 @@ module ServicesHelper
cancel_path: scoped_integrations_path,
can_test: integration.can_test?.to_s,
test_path: scoped_test_integration_path(integration),
- reset_path: ''
+ reset_path: reset_integration?(integration, group: group) ? scoped_reset_integration_path(integration, group: group) : ''
}
end
@@ -114,14 +122,14 @@ module ServicesHelper
false
end
- def group_level_integrations?
- @group.present? && Feature.enabled?(:group_level_integrations, @group, default_enabled: true)
- end
-
def instance_level_integrations?
!Gitlab.com?
end
+ def reset_integration?(integration, group: nil)
+ integration.persisted? && Feature.enabled?(:reset_integrations, group, type: :development)
+ end
+
extend self
private
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 10174e5d719..38758957dba 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module SortingHelper
+ include SortingTitlesValuesHelper
+
def sort_options_hash
{
sort_value_created_date => sort_title_created_date,
@@ -40,6 +42,7 @@ module SortingHelper
sort_value_latest_activity => sort_title_latest_activity,
sort_value_recently_created => sort_title_created_date,
sort_value_name => sort_title_name,
+ sort_value_name_desc => sort_title_name_desc,
sort_value_stars_desc => sort_title_stars
}
@@ -95,8 +98,8 @@ module SortingHelper
sort_value_name_desc => sort_title_name_desc,
sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created,
- sort_value_recently_updated => sort_title_recently_updated,
- sort_value_oldest_updated => sort_title_oldest_updated
+ sort_value_latest_activity => sort_title_recently_updated,
+ sort_value_oldest_activity => sort_title_oldest_updated
}
end
@@ -112,19 +115,6 @@ module SortingHelper
)
end
- def member_sort_options_hash
- {
- sort_value_access_level_asc => sort_title_access_level_asc,
- sort_value_access_level_desc => sort_title_access_level_desc,
- sort_value_last_joined => sort_title_last_joined,
- sort_value_name => sort_title_name_asc,
- sort_value_name_desc => sort_title_name_desc,
- sort_value_oldest_joined => sort_title_oldest_joined,
- sort_value_oldest_signin => sort_title_oldest_signin,
- sort_value_recently_signin => sort_title_recently_signin
- }
- end
-
def milestone_sort_options_hash
{
sort_value_name => sort_title_name_asc,
@@ -186,6 +176,19 @@ module SortingHelper
}
end
+ def member_sort_options_hash
+ {
+ sort_value_access_level_asc => sort_title_access_level_asc,
+ sort_value_access_level_desc => sort_title_access_level_desc,
+ sort_value_last_joined => sort_title_last_joined,
+ sort_value_name => sort_title_name_asc,
+ sort_value_name_desc => sort_title_name_desc,
+ sort_value_oldest_joined => sort_title_oldest_joined,
+ sort_value_oldest_signin => sort_title_oldest_signin,
+ sort_value_recently_signin => sort_title_recently_signin
+ }
+ end
+
def sortable_item(item, path, sorted_by)
link_to item, path, class: sorted_by == item ? 'is-active' : ''
end
@@ -275,340 +278,6 @@ module SortingHelper
sort_direction_button(url, reverse_sort, sort_value)
end
- # Titles.
- def sort_title_access_level_asc
- s_('SortOptions|Access level, ascending')
- end
-
- def sort_title_access_level_desc
- s_('SortOptions|Access level, descending')
- end
-
- def sort_title_created_date
- s_('SortOptions|Created date')
- end
-
- def sort_title_downvotes
- s_('SortOptions|Least popular')
- end
-
- def sort_title_due_date
- s_('SortOptions|Due date')
- end
-
- def sort_title_due_date_later
- s_('SortOptions|Due later')
- end
-
- def sort_title_due_date_soon
- s_('SortOptions|Due soon')
- end
-
- def sort_title_label_priority
- s_('SortOptions|Label priority')
- end
-
- def sort_title_largest_group
- s_('SortOptions|Largest group')
- end
-
- def sort_title_largest_repo
- s_('SortOptions|Largest repository')
- end
-
- def sort_title_last_joined
- s_('SortOptions|Last joined')
- end
-
- def sort_title_latest_activity
- s_('SortOptions|Last updated')
- end
-
- def sort_title_milestone
- s_('SortOptions|Milestone due date')
- end
-
- def sort_title_milestone_later
- s_('SortOptions|Milestone due later')
- end
-
- def sort_title_milestone_soon
- s_('SortOptions|Milestone due soon')
- end
-
- def sort_title_name
- s_('SortOptions|Name')
- end
-
- def sort_title_name_asc
- s_('SortOptions|Name, ascending')
- end
-
- def sort_title_name_desc
- s_('SortOptions|Name, descending')
- end
-
- def sort_title_oldest_activity
- s_('SortOptions|Oldest updated')
- end
-
- def sort_title_oldest_created
- s_('SortOptions|Oldest created')
- end
-
- def sort_title_oldest_joined
- s_('SortOptions|Oldest joined')
- end
-
- def sort_title_oldest_signin
- s_('SortOptions|Oldest sign in')
- end
-
- def sort_title_oldest_starred
- s_('SortOptions|Oldest starred')
- end
-
- def sort_title_oldest_updated
- s_('SortOptions|Oldest updated')
- end
-
- def sort_title_popularity
- s_('SortOptions|Popularity')
- end
-
- def sort_title_priority
- s_('SortOptions|Priority')
- end
-
- def sort_title_recently_created
- s_('SortOptions|Last created')
- end
-
- def sort_title_recently_signin
- s_('SortOptions|Recent sign in')
- end
-
- def sort_title_recently_starred
- s_('SortOptions|Recently starred')
- end
-
- def sort_title_recently_updated
- s_('SortOptions|Last updated')
- end
-
- def sort_title_start_date_later
- s_('SortOptions|Start later')
- end
-
- def sort_title_start_date_soon
- s_('SortOptions|Start soon')
- end
-
- def sort_title_upvotes
- s_('SortOptions|Most popular')
- end
-
- def sort_title_contacted_date
- s_('SortOptions|Last Contact')
- end
-
- def sort_title_most_stars
- s_('SortOptions|Most stars')
- end
-
- def sort_title_stars
- s_('SortOptions|Stars')
- end
-
- def sort_title_oldest_last_activity
- s_('SortOptions|Oldest last activity')
- end
-
- def sort_title_recently_last_activity
- s_('SortOptions|Recent last activity')
- end
-
- def sort_title_relative_position
- s_('SortOptions|Manual')
- end
-
- def sort_title_size
- s_('SortOptions|Size')
- end
-
- def sort_title_expire_date
- s_('SortOptions|Expired date')
- end
-
- def sort_title_relevant
- s_('SortOptions|Relevant')
- end
-
- # Values.
- def sort_value_access_level_asc
- 'access_level_asc'
- end
-
- def sort_value_access_level_desc
- 'access_level_desc'
- end
-
- def sort_value_created_date
- 'created_date'
- end
-
- def sort_value_downvotes
- 'downvotes_desc'
- end
-
- def sort_value_due_date
- 'due_date'
- end
-
- def sort_value_due_date_later
- 'due_date_desc'
- end
-
- def sort_value_due_date_soon
- 'due_date_asc'
- end
-
- def sort_value_label_priority
- 'label_priority'
- end
-
- def sort_value_largest_group
- 'storage_size_desc'
- end
-
- def sort_value_largest_repo
- 'storage_size_desc'
- end
-
- def sort_value_last_joined
- 'last_joined'
- end
-
- def sort_value_latest_activity
- 'latest_activity_desc'
- end
-
- def sort_value_milestone
- 'milestone'
- end
-
- def sort_value_milestone_later
- 'milestone_due_desc'
- end
-
- def sort_value_milestone_soon
- 'milestone_due_asc'
- end
-
- def sort_value_name
- 'name_asc'
- end
-
- def sort_value_name_desc
- 'name_desc'
- end
-
- def sort_value_oldest_activity
- 'latest_activity_asc'
- end
-
- def sort_value_oldest_created
- 'created_asc'
- end
-
- def sort_value_oldest_signin
- 'oldest_sign_in'
- end
-
- def sort_value_oldest_joined
- 'oldest_joined'
- end
-
- def sort_value_oldest_updated
- 'updated_asc'
- end
-
- def sort_value_popularity
- 'popularity'
- end
-
- def sort_value_most_popular
- 'popularity_desc'
- end
-
- def sort_value_least_popular
- 'popularity_asc'
- end
-
- def sort_value_priority
- 'priority'
- end
-
- def sort_value_recently_created
- 'created_desc'
- end
-
- def sort_value_recently_signin
- 'recent_sign_in'
- end
-
- def sort_value_recently_updated
- 'updated_desc'
- end
-
- def sort_value_start_date_later
- 'start_date_desc'
- end
-
- def sort_value_start_date_soon
- 'start_date_asc'
- end
-
- def sort_value_upvotes
- 'upvotes_desc'
- end
-
- def sort_value_contacted_date
- 'contacted_asc'
- end
-
- def sort_value_stars_desc
- 'stars_desc'
- end
-
- def sort_value_stars_asc
- 'stars_asc'
- end
-
- def sort_value_oldest_last_activity
- 'last_activity_on_asc'
- end
-
- def sort_value_recently_last_activity
- 'last_activity_on_desc'
- end
-
- def sort_value_relative_position
- 'relative_position'
- end
-
- def sort_value_size
- 'size_desc'
- end
-
- def sort_value_expire_date
- 'expired_asc'
- end
-
- def sort_value_relevant
- 'relevant'
- end
-
def packages_sort_options_hash
{
sort_value_recently_created => sort_title_created_date,
diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb
new file mode 100644
index 00000000000..27f3638dc73
--- /dev/null
+++ b/app/helpers/sorting_titles_values_helper.rb
@@ -0,0 +1,339 @@
+# frozen_string_literal: true
+
+module SortingTitlesValuesHelper
+ # Titles.
+ def sort_title_access_level_asc
+ s_('SortOptions|Access level, ascending')
+ end
+
+ def sort_title_access_level_desc
+ s_('SortOptions|Access level, descending')
+ end
+
+ def sort_title_created_date
+ s_('SortOptions|Created date')
+ end
+
+ def sort_title_downvotes
+ s_('SortOptions|Least popular')
+ end
+
+ def sort_title_due_date
+ s_('SortOptions|Due date')
+ end
+
+ def sort_title_due_date_later
+ s_('SortOptions|Due later')
+ end
+
+ def sort_title_due_date_soon
+ s_('SortOptions|Due soon')
+ end
+
+ def sort_title_label_priority
+ s_('SortOptions|Label priority')
+ end
+
+ def sort_title_largest_group
+ s_('SortOptions|Largest group')
+ end
+
+ def sort_title_largest_repo
+ s_('SortOptions|Largest repository')
+ end
+
+ def sort_title_last_joined
+ s_('SortOptions|Last joined')
+ end
+
+ def sort_title_latest_activity
+ s_('SortOptions|Last updated')
+ end
+
+ def sort_title_milestone
+ s_('SortOptions|Milestone due date')
+ end
+
+ def sort_title_milestone_later
+ s_('SortOptions|Milestone due later')
+ end
+
+ def sort_title_milestone_soon
+ s_('SortOptions|Milestone due soon')
+ end
+
+ def sort_title_name
+ s_('SortOptions|Name')
+ end
+
+ def sort_title_name_asc
+ s_('SortOptions|Name, ascending')
+ end
+
+ def sort_title_name_desc
+ s_('SortOptions|Name, descending')
+ end
+
+ def sort_title_oldest_activity
+ s_('SortOptions|Oldest updated')
+ end
+
+ def sort_title_oldest_created
+ s_('SortOptions|Oldest created')
+ end
+
+ def sort_title_oldest_joined
+ s_('SortOptions|Oldest joined')
+ end
+
+ def sort_title_oldest_signin
+ s_('SortOptions|Oldest sign in')
+ end
+
+ def sort_title_oldest_starred
+ s_('SortOptions|Oldest starred')
+ end
+
+ def sort_title_oldest_updated
+ s_('SortOptions|Oldest updated')
+ end
+
+ def sort_title_popularity
+ s_('SortOptions|Popularity')
+ end
+
+ def sort_title_priority
+ s_('SortOptions|Priority')
+ end
+
+ def sort_title_recently_created
+ s_('SortOptions|Last created')
+ end
+
+ def sort_title_recently_signin
+ s_('SortOptions|Recent sign in')
+ end
+
+ def sort_title_recently_starred
+ s_('SortOptions|Recently starred')
+ end
+
+ def sort_title_recently_updated
+ s_('SortOptions|Last updated')
+ end
+
+ def sort_title_start_date_later
+ s_('SortOptions|Start later')
+ end
+
+ def sort_title_start_date_soon
+ s_('SortOptions|Start soon')
+ end
+
+ def sort_title_upvotes
+ s_('SortOptions|Most popular')
+ end
+
+ def sort_title_contacted_date
+ s_('SortOptions|Last Contact')
+ end
+
+ def sort_title_most_stars
+ s_('SortOptions|Most stars')
+ end
+
+ def sort_title_stars
+ s_('SortOptions|Stars')
+ end
+
+ def sort_title_oldest_last_activity
+ s_('SortOptions|Oldest last activity')
+ end
+
+ def sort_title_recently_last_activity
+ s_('SortOptions|Recent last activity')
+ end
+
+ def sort_title_relative_position
+ s_('SortOptions|Manual')
+ end
+
+ def sort_title_size
+ s_('SortOptions|Size')
+ end
+
+ def sort_title_expire_date
+ s_('SortOptions|Expired date')
+ end
+
+ def sort_title_relevant
+ s_('SortOptions|Relevant')
+ end
+
+ # Values.
+ def sort_value_access_level_asc
+ 'access_level_asc'
+ end
+
+ def sort_value_access_level_desc
+ 'access_level_desc'
+ end
+
+ def sort_value_created_date
+ 'created_date'
+ end
+
+ def sort_value_downvotes
+ 'downvotes_desc'
+ end
+
+ def sort_value_due_date
+ 'due_date'
+ end
+
+ def sort_value_due_date_later
+ 'due_date_desc'
+ end
+
+ def sort_value_due_date_soon
+ 'due_date_asc'
+ end
+
+ def sort_value_label_priority
+ 'label_priority'
+ end
+
+ def sort_value_largest_group
+ 'storage_size_desc'
+ end
+
+ def sort_value_largest_repo
+ 'storage_size_desc'
+ end
+
+ def sort_value_last_joined
+ 'last_joined'
+ end
+
+ def sort_value_latest_activity
+ 'latest_activity_desc'
+ end
+
+ def sort_value_milestone
+ 'milestone'
+ end
+
+ def sort_value_milestone_later
+ 'milestone_due_desc'
+ end
+
+ def sort_value_milestone_soon
+ 'milestone_due_asc'
+ end
+
+ def sort_value_name
+ 'name_asc'
+ end
+
+ def sort_value_name_desc
+ 'name_desc'
+ end
+
+ def sort_value_oldest_activity
+ 'latest_activity_asc'
+ end
+
+ def sort_value_oldest_created
+ 'created_asc'
+ end
+
+ def sort_value_oldest_signin
+ 'oldest_sign_in'
+ end
+
+ def sort_value_oldest_joined
+ 'oldest_joined'
+ end
+
+ def sort_value_oldest_updated
+ 'updated_asc'
+ end
+
+ def sort_value_popularity
+ 'popularity'
+ end
+
+ def sort_value_most_popular
+ 'popularity_desc'
+ end
+
+ def sort_value_least_popular
+ 'popularity_asc'
+ end
+
+ def sort_value_priority
+ 'priority'
+ end
+
+ def sort_value_recently_created
+ 'created_desc'
+ end
+
+ def sort_value_recently_signin
+ 'recent_sign_in'
+ end
+
+ def sort_value_recently_updated
+ 'updated_desc'
+ end
+
+ def sort_value_start_date_later
+ 'start_date_desc'
+ end
+
+ def sort_value_start_date_soon
+ 'start_date_asc'
+ end
+
+ def sort_value_upvotes
+ 'upvotes_desc'
+ end
+
+ def sort_value_contacted_date
+ 'contacted_asc'
+ end
+
+ def sort_value_stars_desc
+ 'stars_desc'
+ end
+
+ def sort_value_stars_asc
+ 'stars_asc'
+ end
+
+ def sort_value_oldest_last_activity
+ 'last_activity_on_asc'
+ end
+
+ def sort_value_recently_last_activity
+ 'last_activity_on_desc'
+ end
+
+ def sort_value_relative_position
+ 'relative_position'
+ end
+
+ def sort_value_size
+ 'size_desc'
+ end
+
+ def sort_value_expire_date
+ 'expired_asc'
+ end
+
+ def sort_value_relevant
+ 'relevant'
+ end
+end
+
+SortingHelper.include_if_ee('::EE::SortingTitlesValuesHelper')
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index 13bf9c92d52..d6a4d6ac57a 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -15,9 +15,11 @@ module StorageHelper
counter_wikis: storage_counter(statistics.wiki_size),
counter_build_artifacts: storage_counter(statistics.build_artifacts_size),
counter_lfs_objects: storage_counter(statistics.lfs_objects_size),
- counter_snippets: storage_counter(statistics.snippets_size)
+ counter_snippets: storage_counter(statistics.snippets_size),
+ counter_packages: storage_counter(statistics.packages_size),
+ counter_uploads: storage_counter(statistics.uploads_size)
}
- _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets}") % counters
+ _("Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}") % counters
end
end
diff --git a/app/helpers/suggest_pipeline_helper.rb b/app/helpers/suggest_pipeline_helper.rb
index 3151b792344..f0a12f0e268 100644
--- a/app/helpers/suggest_pipeline_helper.rb
+++ b/app/helpers/suggest_pipeline_helper.rb
@@ -2,8 +2,6 @@
module SuggestPipelineHelper
def should_suggest_gitlab_ci_yml?
- Feature.enabled?(:suggest_pipeline, default_enabled: true) &&
- current_user &&
- params[:suggest_gitlab_ci_yml] == 'true'
+ current_user && params[:suggest_gitlab_ci_yml] == 'true'
end
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 79f4810e13a..85e644967ea 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -38,7 +38,8 @@ module SystemNoteHelper
'status' => 'status',
'alert_issue_added' => 'issues',
'new_alert_added' => 'warning',
- 'severity' => 'information-o'
+ 'severity' => 'information-o',
+ 'cloned' => 'documents'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/time_zone_helper.rb b/app/helpers/time_zone_helper.rb
new file mode 100644
index 00000000000..00f65b72c8e
--- /dev/null
+++ b/app/helpers/time_zone_helper.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module TimeZoneHelper
+ TIME_ZONE_FORMAT_ATTRS = {
+ short: %i[identifier name offset],
+ full: %i[identifier name abbr offset formatted_offset]
+ }.freeze
+ private_constant :TIME_ZONE_FORMAT_ATTRS
+
+ # format:
+ # * :full - all available fields
+ # * :short (default)
+ #
+ # Example:
+ # timezone_data # :short by default
+ # timezone_data(format: :full)
+ #
+ def timezone_data(format: :short)
+ attrs = TIME_ZONE_FORMAT_ATTRS.fetch(format) do
+ valid_formats = TIME_ZONE_FORMAT_ATTRS.keys.map { |k| ":#{k}"}.join(", ")
+ raise ArgumentError.new("Invalid format :#{format}. Valid formats are #{valid_formats}.")
+ end
+
+ ActiveSupport::TimeZone.all.map do |timezone|
+ {
+ identifier: timezone.tzinfo.identifier,
+ name: timezone.name,
+ abbr: timezone.tzinfo.strftime('%Z'),
+ offset: timezone.now.utc_offset,
+ formatted_offset: timezone.now.formatted_offset
+ }.slice(*attrs)
+ end
+ end
+end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index 692971f4627..f24aa5d3bcb 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -228,12 +228,12 @@ module TreeHelper
gitpod_enabled: !current_user.nil? && current_user.gitpod_enabled,
is_blob: !options[:blob].nil?,
- show_edit_button: show_edit_button?,
+ show_edit_button: show_edit_button?(options),
show_web_ide_button: show_web_ide_button?,
show_gitpod_button: show_gitpod_button?,
web_ide_url: web_ide_url,
- edit_url: edit_url,
+ edit_url: edit_url(options),
gitpod_url: gitpod_url
}
end
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index e93c1b82cd7..a06a31ddf32 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -57,7 +57,10 @@ module UserCalloutsHelper
end
def show_registration_enabled_user_callout?
- current_user&.admin? && signup_enabled? && !user_dismissed?(REGISTRATION_ENABLED_CALLOUT)
+ !Gitlab.com? &&
+ current_user&.admin? &&
+ signup_enabled? &&
+ !user_dismissed?(REGISTRATION_ENABLED_CALLOUT)
end
private
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index 7d4ab192f2f..a58f8a6f792 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -1,6 +1,13 @@
# frozen_string_literal: true
module UsersHelper
+ def admin_users_data_attributes(users)
+ {
+ users: Admin::UserSerializer.new.represent(users).to_json,
+ paths: admin_users_paths.to_json
+ }
+ end
+
def user_link(user)
link_to(user.name, user_path(user),
title: user.email,
@@ -60,6 +67,12 @@ module UsersHelper
"access:#{max_project_member_access(project)}"
end
+ def show_status_emoji?(status)
+ return false unless status
+
+ status.message.present? || status.emoji != UserStatus::DEFAULT_EMOJI
+ end
+
def user_status(user)
return unless user
@@ -123,6 +136,19 @@ module UsersHelper
}
end
+ def user_unblock_data(user)
+ {
+ path: unblock_admin_user_path(user),
+ method: 'put',
+ modal_attributes: {
+ title: s_('AdminUsers|Unblock user %{username}?') % { username: sanitize_name(user.name) },
+ message: s_('AdminUsers|You can always block their account again if needed.'),
+ okVariant: 'info',
+ okTitle: s_('AdminUsers|Unblock')
+ }.to_json
+ }
+ end
+
def user_block_effects
header = tag.p s_('AdminUsers|Blocking user has the following effects:')
@@ -136,8 +162,75 @@ module UsersHelper
header + list
end
+ def user_deactivation_data(user, message)
+ {
+ path: deactivate_admin_user_path(user),
+ method: 'put',
+ modal_attributes: {
+ title: s_('AdminUsers|Deactivate user %{username}?') % { username: sanitize_name(user.name) },
+ messageHtml: message,
+ okVariant: 'warning',
+ okTitle: s_('AdminUsers|Deactivate')
+ }.to_json
+ }
+ end
+
+ def user_activation_data(user)
+ {
+ path: activate_admin_user_path(user),
+ method: 'put',
+ modal_attributes: {
+ title: s_('AdminUsers|Activate user %{username}?') % { username: sanitize_name(user.name) },
+ message: s_('AdminUsers|You can always deactivate their account again if needed.'),
+ okVariant: 'info',
+ okTitle: s_('AdminUsers|Activate')
+ }.to_json
+ }
+ end
+
+ def user_deactivation_effects
+ header = tag.p s_('AdminUsers|Deactivating a user has the following effects:')
+
+ list = tag.ul do
+ concat tag.li s_('AdminUsers|The user will be logged out')
+ concat tag.li s_('AdminUsers|The user will not be able to access git repositories')
+ concat tag.li s_('AdminUsers|The user will not be able to access the API')
+ concat tag.li s_('AdminUsers|The user will not receive any notifications')
+ concat tag.li s_('AdminUsers|The user will not be able to use slash commands')
+ concat tag.li s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account')
+ concat tag.li s_('AdminUsers|Personal projects, group and user history will be left intact')
+ end
+
+ header + list
+ end
+
+ def user_display_name(user)
+ return s_('UserProfile|Blocked user') if user.blocked?
+
+ can_read_profile = can?(current_user, :read_user_profile, user)
+ return s_('UserProfile|Unconfirmed user') unless user.confirmed? || can_read_profile
+
+ user.name
+ end
+
private
+ def admin_users_paths
+ {
+ edit: edit_admin_user_path(:id),
+ approve: approve_admin_user_path(:id),
+ reject: reject_admin_user_path(:id),
+ unblock: unblock_admin_user_path(:id),
+ block: block_admin_user_path(:id),
+ deactivate: deactivate_admin_user_path(:id),
+ activate: activate_admin_user_path(:id),
+ unlock: unlock_admin_user_path(:id),
+ delete: admin_user_path(:id),
+ delete_with_contributions: admin_user_path(:id),
+ admin_user: admin_user_path(:id)
+ }
+ end
+
def blocked_user_badge(user)
pending_approval_badge = { text: s_('AdminUsers|Pending approval'), variant: 'info' }
return pending_approval_badge if user.blocked_pending_approval?
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 896dcdd2caf..0a37257e124 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -157,6 +157,16 @@ module VisibilityLevelHelper
end
end
+ def visibility_level_options(form_model)
+ available_visibility_levels(form_model).map do |level|
+ {
+ level: level,
+ label: visibility_level_label(level),
+ description: visibility_level_description(level, form_model)
+ }
+ end
+ end
+
def snippets_selected_visibility_level(visibility_levels, selected)
visibility_levels.find { |level| level == selected } || visibility_levels.min
end
diff --git a/app/helpers/web_ide_button_helper.rb b/app/helpers/web_ide_button_helper.rb
index 0a4d47eed52..7aa0adc31bd 100644
--- a/app/helpers/web_ide_button_helper.rb
+++ b/app/helpers/web_ide_button_helper.rb
@@ -21,8 +21,8 @@ module WebIdeButtonHelper
can_collaborate? || can_create_mr_from_fork?
end
- def show_edit_button?
- readable_blob? && show_web_ide_button?
+ def show_edit_button?(options = {})
+ readable_blob?(options) && show_web_ide_button?
end
def show_gitpod_button?
@@ -37,8 +37,8 @@ module WebIdeButtonHelper
!project_fork.nil? && !can_push_code?
end
- def readable_blob?
- !readable_blob({}, @path, @project, @ref).nil?
+ def readable_blob?(options = {})
+ !readable_blob(options, @path, @project, @ref).nil?
end
def needs_to_fork?
@@ -49,8 +49,8 @@ module WebIdeButtonHelper
ide_edit_path(project_to_use, @ref, @path || '')
end
- def edit_url
- readable_blob? ? edit_blob_path(@project, @ref, @path || '') : ''
+ def edit_url(options = {})
+ readable_blob?(options) ? edit_blob_path(@project, @ref, @path || '') : ''
end
def gitpod_url
diff --git a/app/helpers/whats_new_helper.rb b/app/helpers/whats_new_helper.rb
index 283d443f51b..bbf5bde5904 100644
--- a/app/helpers/whats_new_helper.rb
+++ b/app/helpers/whats_new_helper.rb
@@ -1,25 +1,19 @@
# frozen_string_literal: true
module WhatsNewHelper
- include Gitlab::WhatsNew
-
def whats_new_most_recent_release_items_count
- Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_items_count', expires_in: CACHE_DURATION) do
- whats_new_release_items&.count
- end
+ ReleaseHighlight.most_recent_item_count
end
def whats_new_storage_key
- return unless whats_new_most_recent_version
+ most_recent_version = ReleaseHighlight.versions&.first
- ['display-whats-new-notification', whats_new_most_recent_version].join('-')
- end
+ return unless most_recent_version
- private
+ ['display-whats-new-notification', most_recent_version].join('-')
+ end
- def whats_new_most_recent_version
- Gitlab::ProcessMemoryCache.cache_backend.fetch('whats_new:release_version', expires_in: CACHE_DURATION) do
- whats_new_release_items&.first&.[]('release')
- end
+ def whats_new_versions
+ ReleaseHighlight.versions
end
end
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 10a1da90e9e..b2c1351bd28 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -80,6 +80,16 @@ module Emails
mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
end
+ def issue_cloned_email(recipient, issue, new_issue, updated_by_user, reason = nil)
+ setup_issue_mail(issue.id, recipient.id)
+
+ @author = updated_by_user
+ @issue = issue
+ @new_issue = new_issue
+ @can_access_project = recipient.can?(:read_project, @new_issue.project)
+ mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
+ end
+
def import_issues_csv_email(user_id, project_id, results)
@user = User.find(user_id)
@project = Project.find(project_id)
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 0b5a8dfdc24..759181bd3cb 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -63,15 +63,6 @@ module Emails
subject: subject_line,
layout: 'unknown_user_mailer'
)
-
- if Gitlab::Experimentation.enabled?(:invitation_reminders)
- Gitlab::Tracking.event(
- Gitlab::Experimentation.experiment(:invitation_reminders).tracking_category,
- 'sent',
- property: Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, member.invite_email) ? 'experimental_group' : 'control_group',
- label: Digest::MD5.hexdigest(member.to_global_id.to_s)
- )
- end
end
def member_invited_reminder_email(member_source_type, member_id, token, reminder_index)
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index 6f44b63f8d0..e3c72a343e7 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -18,6 +18,14 @@ module Emails
subject: subject(_("GitLab Account Request")))
end
+ def user_admin_rejection_email(name, email)
+ @name = name
+
+ profile_email_with_layout(
+ to: email,
+ subject: subject(_("GitLab account request rejected")))
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def new_ssh_key_email(key_id)
@key = Key.find_by(id: key_id)
diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb
index fa646487819..4dceff5b7ba 100644
--- a/app/mailers/emails/service_desk.rb
+++ b/app/mailers/emails/service_desk.rb
@@ -47,7 +47,7 @@ module Emails
def service_desk_options(email_sender, email_type)
{
from: email_sender,
- to: @issue.service_desk_reply_to
+ to: @issue.external_author
}.tap do |options|
next unless template_body = template_content(email_type)
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index 7ce7f40b6a8..7090d9f4ea1 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -69,6 +69,11 @@ module AlertManagement
unknown: 5
}
+ enum domain: {
+ operations: 0,
+ threat_monitoring: 1
+ }
+
state_machine :status, initial: :triggered do
state :triggered, value: STATUSES[:triggered]
@@ -122,6 +127,8 @@ module AlertManagement
scope :open, -> { with_status(open_statuses) }
scope :not_resolved, -> { without_status(:resolved) }
scope :with_prometheus_alert, -> { includes(:prometheus_alert) }
+ scope :with_threat_monitoring_alerts, -> { where(domain: :threat_monitoring ) }
+ scope :with_operations_alerts, -> { where(domain: :operations) }
scope :order_start_time, -> (sort_order) { order(started_at: sort_order) }
scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) }
@@ -263,3 +270,5 @@ module AlertManagement
end
end
end
+
+AlertManagement::Alert.prepend_if_ee('EE::AlertManagement::Alert')
diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb
index ae5170867c3..0c916c576cb 100644
--- a/app/models/alert_management/http_integration.rb
+++ b/app/models/alert_management/http_integration.rb
@@ -22,6 +22,7 @@ module AlertManagement
validates :name, presence: true, length: { maximum: 255 }
validates :endpoint_identifier, presence: true, length: { maximum: 255 }, format: { with: /\A[A-Za-z0-9]+\z/ }
validates :endpoint_identifier, uniqueness: { scope: [:project_id, :active] }, if: :active?
+ validates :payload_attribute_mapping, json_schema: { filename: 'http_integration_payload_attribute_mapping' }
before_validation :prevent_token_assignment
before_validation :prevent_endpoint_identifier_assignment
diff --git a/app/models/analytics/devops_adoption.rb b/app/models/analytics/devops_adoption.rb
deleted file mode 100644
index ed5a5b16a6e..00000000000
--- a/app/models/analytics/devops_adoption.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-# frozen_string_literal: true
-module Analytics::DevopsAdoption
- def self.table_name_prefix
- 'analytics_devops_adoption_'
- end
-end
diff --git a/app/models/analytics/devops_adoption/segment.rb b/app/models/analytics/devops_adoption/segment.rb
deleted file mode 100644
index 71d4a312627..00000000000
--- a/app/models/analytics/devops_adoption/segment.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-class Analytics::DevopsAdoption::Segment < ApplicationRecord
- ALLOWED_SEGMENT_COUNT = 20
-
- has_many :segment_selections
- has_many :groups, through: :segment_selections
-
- validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
- validate :validate_segment_count
-
- accepts_nested_attributes_for :segment_selections, allow_destroy: true
-
- scope :ordered_by_name, -> { order(:name) }
- scope :with_groups, -> { preload(:groups) }
-
- private
-
- def validate_segment_count
- if self.class.count >= ALLOWED_SEGMENT_COUNT
- errors.add(:name, s_('DevopsAdoptionSegment|The maximum number of segments has been reached'))
- end
- end
-end
diff --git a/app/models/analytics/devops_adoption/segment_selection.rb b/app/models/analytics/devops_adoption/segment_selection.rb
deleted file mode 100644
index 6b70c13a773..00000000000
--- a/app/models/analytics/devops_adoption/segment_selection.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-class Analytics::DevopsAdoption::SegmentSelection < ApplicationRecord
- ALLOWED_SELECTIONS_PER_SEGMENT = 20
-
- belongs_to :segment
- belongs_to :project
- belongs_to :group
-
- validates :segment, presence: true
- validates :project, presence: { unless: :group }
- validates :project_id, uniqueness: { scope: :segment_id, if: :project }
- validates :group, presence: { unless: :project }
- validates :group_id, uniqueness: { scope: :segment_id, if: :group }
-
- validate :exclusive_project_or_group
- validate :validate_selection_count
-
- private
-
- def exclusive_project_or_group
- if project.present? && group.present?
- errors.add(:group, s_('DevopsAdoptionSegmentSelection|The selection cannot be configured for a project and for a group at the same time'))
- end
- end
-
- def validate_selection_count
- return unless segment
-
- selection_count_for_segment = self.class.where(segment: segment).count
-
- if selection_count_for_segment >= ALLOWED_SELECTIONS_PER_SEGMENT
- errors.add(:segment, s_('DevopsAdoptionSegmentSelection|The maximum number of selections has been reached'))
- end
- end
-end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 7bfa5fb4cb8..9b9db7f93fd 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -5,14 +5,14 @@ class ApplicationSetting < ApplicationRecord
include CacheMarkdownField
include TokenAuthenticatable
include ChronicDurationAttribute
- include IgnorableColumns
-
- ignore_column :namespace_storage_size_limit, remove_with: '13.5', remove_after: '2020-09-22'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
'Admin Area > Settings > Metrics and profiling > Metrics - Grafana'
+ KROKI_URL_ERROR_MESSAGE = 'Please check your Kroki URL setting in ' \
+ 'Admin Area > Settings > General > Kroki'
+
add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required }
add_authentication_token_field :health_check_access_token
add_authentication_token_field :static_objects_external_storage_auth_token
@@ -128,6 +128,11 @@ class ApplicationSetting < ApplicationRecord
presence: true,
if: :unique_ips_limit_enabled
+ validates :kroki_url,
+ presence: { if: :kroki_enabled }
+
+ validate :validate_kroki_url, if: :kroki_enabled
+
validates :plantuml_url,
presence: true,
if: :plantuml_enabled
@@ -244,6 +249,12 @@ class ApplicationSetting < ApplicationRecord
validates :user_default_internal_regex, js_regex: true, allow_nil: true
+ validates :personal_access_token_prefix,
+ format: { with: /\A[a-zA-Z0-9_+=\/@:.-]+\z/,
+ message: _("can contain only letters of the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.'") },
+ length: { maximum: 20, message: _('is too long (maximum is %{count} characters)') },
+ allow_blank: true
+
validates :commit_email_hostname, format: { with: /\A[^@]+\z/ }
validates :archive_builds_in_seconds,
@@ -362,11 +373,11 @@ class ApplicationSetting < ApplicationRecord
validates :eks_access_key_id,
length: { in: 16..128 },
- if: :eks_integration_enabled?
+ if: -> (setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? }
validates :eks_secret_access_key,
presence: true,
- if: :eks_integration_enabled?
+ if: -> (setting) { setting.eks_integration_enabled? && setting.eks_access_key_id.present? }
validates_with X509CertificateCredentialsValidator,
certificate: :external_auth_client_cert,
@@ -418,6 +429,9 @@ class ApplicationSetting < ApplicationRecord
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
+ validates :disable_feed_token,
+ inclusion: { in: [true, false], message: 'must be a boolean value' }
+
before_validation :ensure_uuid!
before_save :ensure_runners_registration_token
@@ -429,18 +443,21 @@ class ApplicationSetting < ApplicationRecord
after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') }
def validate_grafana_url
- unless parsed_grafana_url
- self.errors.add(
- :grafana_url,
- "must be a valid relative or absolute URL. #{GRAFANA_URL_ERROR_MESSAGE}"
- )
- end
+ validate_url(parsed_grafana_url, :grafana_url, GRAFANA_URL_ERROR_MESSAGE)
end
def grafana_url_absolute?
parsed_grafana_url&.absolute?
end
+ def validate_kroki_url
+ validate_url(parsed_kroki_url, :kroki_url, KROKI_URL_ERROR_MESSAGE)
+ end
+
+ def kroki_url_absolute?
+ parsed_kroki_url&.absolute?
+ end
+
def sourcegraph_url_is_com?
!!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/)
end
@@ -503,6 +520,24 @@ class ApplicationSetting < ApplicationRecord
def parsed_grafana_url
@parsed_grafana_url ||= Gitlab::Utils.parse_url(grafana_url)
end
+
+ def parsed_kroki_url
+ @parsed_kroki_url ||= Gitlab::UrlBlocker.validate!(kroki_url, schemes: %w(http https), enforce_sanitization: true)[0]
+ rescue Gitlab::UrlBlocker::BlockedUrlError => error
+ self.errors.add(
+ :kroki_url,
+ "is not valid. #{error}"
+ )
+ end
+
+ def validate_url(parsed_url, name, error_message)
+ unless parsed_url
+ self.errors.add(
+ name,
+ "must be a valid relative or absolute URL. #{error_message}"
+ )
+ end
+ end
end
ApplicationSetting.prepend_if_ee('EE::ApplicationSetting')
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 5c7abbccd63..105889a364a 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -58,6 +58,7 @@ module ApplicationSettingImplementation
default_projects_limit: Settings.gitlab['default_projects_limit'],
default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
+ disable_feed_token: false,
disabled_oauth_sign_in_sources: [],
dns_rebinding_protection_enabled: true,
domain_allowlist: Settings.gitlab['domain_allowlist'],
@@ -103,6 +104,7 @@ module ApplicationSettingImplementation
password_authentication_enabled_for_git: true,
password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'],
performance_bar_allowed_group_id: nil,
+ personal_access_token_prefix: nil,
plantuml_enabled: false,
plantuml_url: nil,
polling_interval_multiplier: 1,
@@ -168,7 +170,9 @@ module ApplicationSettingImplementation
user_show_add_ssh_key_message: true,
wiki_page_max_content_bytes: 50.megabytes,
container_registry_delete_tags_service_timeout: 250,
- container_registry_expiration_policies_worker_capacity: 0
+ container_registry_expiration_policies_worker_capacity: 0,
+ kroki_enabled: false,
+ kroki_url: nil
}
end
diff --git a/app/models/approval.rb b/app/models/approval.rb
index bc123de0b20..899ea466315 100644
--- a/app/models/approval.rb
+++ b/app/models/approval.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Approval < ApplicationRecord
+ include CreatedAtFilterable
+
belongs_to :user
belongs_to :merge_request
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 55e8a5d4535..a4d991b040c 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -13,6 +13,8 @@ class AuditEvent < ApplicationRecord
:target_id
].freeze
+ self.primary_key = :id
+
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user, foreign_key: :author_id
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 34030e079c7..a4d0b7485ba 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -30,6 +30,11 @@ class BulkImports::Entity < ApplicationRecord
class_name: 'BulkImports::Tracker',
foreign_key: :bulk_import_entity_id
+ has_many :failures,
+ class_name: 'BulkImports::Failure',
+ inverse_of: :entity,
+ foreign_key: :bulk_import_entity_id
+
validates :project, absence: true, if: :group
validates :group, absence: true, if: :project
validates :source_type, :source_full_path, :destination_name,
@@ -52,6 +57,7 @@ class BulkImports::Entity < ApplicationRecord
event :finish do
transition started: :finished
+ transition failed: :failed
end
event :fail_op do
@@ -59,6 +65,25 @@ 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/failure.rb b/app/models/bulk_imports/failure.rb
new file mode 100644
index 00000000000..a6f7582c3b0
--- /dev/null
+++ b/app/models/bulk_imports/failure.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class BulkImports::Failure < ApplicationRecord
+ self.table_name = 'bulk_import_failures'
+
+ belongs_to :entity,
+ class_name: 'BulkImports::Entity',
+ foreign_key: :bulk_import_entity_id,
+ inverse_of: :failures,
+ optional: false
+
+ validates :entity, presence: true
+end
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 5b23cf46fdb..19a0d424e33 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -132,14 +132,10 @@ module Ci
end
def playable?
- return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
-
action? && !archived? && manual?
end
def action?
- return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
-
%w[manual].include?(self.when)
end
@@ -206,7 +202,7 @@ module Ci
override :dependency_variables
def dependency_variables
- return [] unless ::Feature.enabled?(:ci_bridge_dependency_variables, project)
+ return [] unless ::Feature.enabled?(:ci_bridge_dependency_variables, project, default_enabled: true)
super
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 84abd01786d..71939f070cb 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -190,6 +190,8 @@ module Ci
scope :with_coverage, -> { where.not(coverage: nil) }
+ scope :for_project, -> (project_id) { where(project_id: project_id) }
+
acts_as_taggable
add_authentication_token_field :token, encrypted: :optional
@@ -379,8 +381,16 @@ module Ci
Ci::BuildRunnerSession.where(build: build).delete_all
end
- after_transition any => [:skipped, :canceled] do |build|
- build.deployment&.cancel
+ after_transition any => [:skipped, :canceled] do |build, transition|
+ if Feature.enabled?(:cd_skipped_deployment_status, build.project)
+ if transition.to_name == :skipped
+ build.deployment&.skip
+ else
+ build.deployment&.cancel
+ end
+ else
+ build.deployment&.cancel
+ end
end
end
@@ -527,6 +537,7 @@ module Ci
strong_memoize(:variables) do
Gitlab::Ci::Variables::Collection.new
.concat(persisted_variables)
+ .concat(dependency_proxy_variables)
.concat(job_jwt_variables)
.concat(scoped_variables)
.concat(job_variables)
@@ -575,6 +586,15 @@ module Ci
end
end
+ def dependency_proxy_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless Gitlab.config.dependency_proxy.enabled
+
+ variables.append(key: 'CI_DEPENDENCY_PROXY_USER', value: ::Gitlab::Auth::CI_JOB_USER)
+ variables.append(key: 'CI_DEPENDENCY_PROXY_PASSWORD', value: token.to_s, public: false, masked: true)
+ end
+ end
+
def features
{ trace_sections: true }
end
@@ -908,13 +928,33 @@ module Ci
end
def collect_coverage_reports!(coverage_report)
+ project_path, worktree_paths = if Feature.enabled?(:smart_cobertura_parser, project)
+ # If the flag is disabled, we intentionally pass nil
+ # for both project_path and worktree_paths to fallback
+ # to the non-smart behavior of the parser
+ [project.full_path, pipeline.all_worktree_paths]
+ end
+
each_report(Ci::JobArtifact::COVERAGE_REPORT_FILE_TYPES) do |file_type, blob|
- Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, coverage_report)
+ Gitlab::Ci::Parsers.fabricate!(file_type).parse!(
+ blob,
+ coverage_report,
+ project_path: project_path,
+ worktree_paths: worktree_paths
+ )
end
coverage_report
end
+ def collect_codequality_reports!(codequality_report)
+ each_report(Ci::JobArtifact::CODEQUALITY_REPORT_FILE_TYPES) do |file_type, blob|
+ Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, codequality_report)
+ end
+
+ codequality_report
+ end
+
def collect_terraform_reports!(terraform_reports)
each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact|
::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact)
@@ -966,6 +1006,15 @@ module Ci
::Gitlab.com? ? 500_000 : 0
end
+ def debug_mode?
+ return false unless Feature.enabled?(:restrict_access_to_build_debug_mode, default_enabled: true)
+
+ # TODO: Have `debug_mode?` check against data on sent back from runner
+ # to capture all the ways that variables can be set.
+ # See (https://gitlab.com/gitlab-org/gitlab/-/issues/290955)
+ variables.any? { |variable| variable[:key] == 'CI_DEBUG_TRACE' && variable[:value].casecmp('true') == 0 }
+ end
+
protected
def run_status_commit_hooks!
diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb
index 2fcd1708cf4..a6abeb517c1 100644
--- a/app/models/ci/build_dependencies.rb
+++ b/app/models/ci/build_dependencies.rb
@@ -2,6 +2,8 @@
module Ci
class BuildDependencies
+ include ::Gitlab::Utils::StrongMemoize
+
attr_reader :processable
def initialize(processable)
@@ -9,7 +11,7 @@ module Ci
end
def all
- (local + cross_pipeline).uniq
+ (local + cross_pipeline + cross_project).uniq
end
# Dependencies local to the given pipeline
@@ -23,8 +25,16 @@ module Ci
deps
end
- # Dependencies that are defined in other pipelines
+ # Dependencies from the same parent-pipeline hierarchy excluding
+ # the current job's pipeline
def cross_pipeline
+ strong_memoize(:cross_pipeline) do
+ fetch_dependencies_in_hierarchy
+ end
+ end
+
+ # Dependencies that are defined by project and ref
+ def cross_project
[]
end
@@ -33,7 +43,7 @@ module Ci
end
def valid?
- valid_local? && valid_cross_pipeline?
+ valid_local? && valid_cross_pipeline? && valid_cross_project?
end
private
@@ -44,13 +54,61 @@ module Ci
::Ci::Build
end
+ def fetch_dependencies_in_hierarchy
+ deps_specifications = specified_cross_pipeline_dependencies
+ return [] if deps_specifications.empty?
+
+ deps_specifications = expand_variables_and_validate(deps_specifications)
+ jobs_in_pipeline_hierarchy(deps_specifications)
+ end
+
+ def jobs_in_pipeline_hierarchy(deps_specifications)
+ all_pipeline_ids = []
+ all_job_names = []
+
+ deps_specifications.each do |spec|
+ all_pipeline_ids << spec[:pipeline]
+ all_job_names << spec[:job]
+ end
+
+ model_class.latest.success
+ .in_pipelines(processable.pipeline.same_family_pipeline_ids)
+ .in_pipelines(all_pipeline_ids.uniq)
+ .by_name(all_job_names.uniq)
+ .select do |dependency|
+ # the query may not return exact matches pipeline-job, so we filter
+ # them separately.
+ deps_specifications.find do |spec|
+ spec[:pipeline] == dependency.pipeline_id &&
+ spec[:job] == dependency.name
+ end
+ end
+ end
+
+ def expand_variables_and_validate(specifications)
+ specifications.map do |spec|
+ pipeline = ExpandVariables.expand(spec[:pipeline].to_s, processable_variables).to_i
+ # current pipeline is not allowed because local dependencies
+ # should be used instead.
+ next if pipeline == processable.pipeline_id
+
+ job = ExpandVariables.expand(spec[:job], processable_variables)
+
+ { job: job, pipeline: pipeline }
+ end.compact
+ end
+
+ def valid_cross_pipeline?
+ cross_pipeline.size == specified_cross_pipeline_dependencies.size
+ end
+
def valid_local?
return true if Feature.enabled?(:ci_disable_validates_dependencies)
local.all?(&:valid_dependency?)
end
- def valid_cross_pipeline?
+ def valid_cross_project?
true
end
@@ -78,6 +136,22 @@ module Ci
scope.where(name: processable.options[:dependencies])
end
+
+ def processable_variables
+ -> { processable.simple_variables_without_dependencies }
+ end
+
+ def specified_cross_pipeline_dependencies
+ strong_memoize(:specified_cross_pipeline_dependencies) do
+ next [] unless Feature.enabled?(:ci_cross_pipeline_artifacts_download, processable.project, default_enabled: true)
+
+ specified_cross_dependencies.select { |dep| dep[:pipeline] && dep[:artifacts] }
+ end
+ end
+
+ def specified_cross_dependencies
+ Array(processable.options[:cross_dependencies])
+ end
end
end
diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb
index d3051e3dadc..27b579bf428 100644
--- a/app/models/ci/build_trace_chunks/fog.rb
+++ b/app/models/ci/build_trace_chunks/fog.rb
@@ -14,11 +14,15 @@ module Ci
end
def set_data(model, new_data)
- # TODO: Support AWS S3 server side encryption
- files.create({
- key: key(model),
- body: new_data
- })
+ if Feature.enabled?(:ci_live_trace_use_fog_attributes, default_enabled: true)
+ files.create(create_attributes(model, new_data))
+ else
+ # TODO: Support AWS S3 server side encryption
+ files.create({
+ key: key(model),
+ body: new_data
+ })
+ end
end
def append_data(model, new_data, offset)
@@ -57,6 +61,13 @@ module Ci
key_raw(model.build_id, model.chunk_index)
end
+ def create_attributes(model, new_data)
+ {
+ key: key(model),
+ body: new_data
+ }.merge(object_store_config.fog_attributes)
+ end
+
def key_raw(build_id, chunk_index)
"tmp/builds/#{build_id.to_i}/chunks/#{chunk_index.to_i}.log"
end
@@ -84,6 +95,14 @@ module Ci
def object_store
Gitlab.config.artifacts.object_store
end
+
+ def object_store_raw_config
+ object_store
+ end
+
+ def object_store_config
+ @object_store_config ||= ::ObjectStorage::Config.new(object_store_raw_config)
+ end
end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 7cedd13b407..c80d50ea131 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -7,15 +7,13 @@ module Ci
include UpdateProjectStatistics
include UsageStatistics
include Sortable
- include IgnorableColumns
include Artifactable
include FileStoreMounter
extend Gitlab::Ci::Model
- ignore_columns :locked, remove_after: '2020-07-22', remove_with: '13.4'
-
TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
+ CODEQUALITY_REPORT_FILE_TYPES = %w[codequality].freeze
ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze
NON_ERASABLE_FILE_TYPES = %w[trace].freeze
TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze
@@ -157,6 +155,10 @@ module Ci
with_file_types(COVERAGE_REPORT_FILE_TYPES)
end
+ scope :codequality_reports, -> do
+ with_file_types(CODEQUALITY_REPORT_FILE_TYPES)
+ end
+
scope :terraform_reports, -> do
with_file_types(TERRAFORM_REPORT_FILE_TYPES)
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 8707d635e03..5e5f51d776f 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -7,6 +7,7 @@ module Ci
include Importable
include AfterCommitQueue
include Presentable
+ include Gitlab::Allowable
include Gitlab::OptimisticLocking
include Gitlab::Utils::StrongMemoize
include AtomicInternalId
@@ -16,6 +17,8 @@ module Ci
include FromUnion
include UpdatedAtFilterable
+ MAX_OPEN_MERGE_REQUESTS_REFS = 4
+
PROJECT_ROUTE_AND_NAMESPACE_ROUTE = {
project: [:project_feature, :route, { namespace: :route }]
}.freeze
@@ -104,7 +107,6 @@ module Ci
accepts_nested_attributes_for :variables, reject_if: :persisted?
- delegate :id, to: :project, prefix: true
delegate :full_path, to: :project, prefix: true
validates :sha, presence: { unless: :importing? }
@@ -259,6 +261,22 @@ module Ci
end
end
+ after_transition any => any do |pipeline|
+ next unless Feature.enabled?(:jira_sync_builds, pipeline.project)
+
+ pipeline.run_after_commit do
+ # Passing the seq-id ensures this is idempotent
+ seq_id = ::Atlassian::JiraConnect::Client.generate_update_sequence_id
+ ::JiraConnect::SyncBuildsWorker.perform_async(pipeline.id, seq_id)
+ end
+ end
+
+ after_transition any => ::Ci::Pipeline.completed_statuses do |pipeline|
+ pipeline.run_after_commit do
+ ::Ci::TestFailureHistoryService.new(pipeline).async.perform_if_needed # rubocop: disable CodeReuse/ServiceClass
+ end
+ end
+
after_transition any => [:success, :failed] do |pipeline|
ref_status = pipeline.ci_ref&.update_status_by!(pipeline)
@@ -277,15 +295,17 @@ module Ci
scope :internal, -> { where(source: internal_sources) }
scope :no_child, -> { where.not(source: :parent_pipeline) }
scope :ci_sources, -> { where(source: Enums::Ci::Pipeline.ci_sources.values) }
+ scope :ci_branch_sources, -> { where(source: Enums::Ci::Pipeline.ci_branch_sources.values) }
scope :ci_and_parent_sources, -> { where(source: Enums::Ci::Pipeline.ci_and_parent_sources.values) }
scope :for_user, -> (user) { where(user: user) }
scope :for_sha, -> (sha) { where(sha: sha) }
scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) }
scope :for_sha_or_source_sha, -> (sha) { for_sha(sha).or(for_source_sha(sha)) }
scope :for_ref, -> (ref) { where(ref: ref) }
+ scope :for_branch, -> (branch) { for_ref(branch).where(tag: false) }
scope :for_id, -> (id) { where(id: id) }
scope :for_iid, -> (iid) { where(iid: iid) }
- scope :for_project, -> (project) { where(project: project) }
+ scope :for_project, -> (project_id) { where(project_id: project_id) }
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) }
@@ -310,9 +330,9 @@ module Ci
# In general, please use `Ci::PipelinesForMergeRequestFinder` instead,
# for checking permission of the actor.
scope :triggered_by_merge_request, -> (merge_request) do
- ci_sources.where(source: :merge_request_event,
- merge_request: merge_request,
- project: [merge_request.source_project, merge_request.target_project])
+ where(source: :merge_request_event,
+ merge_request: merge_request,
+ project: [merge_request.source_project, merge_request.target_project])
end
# Returns the pipelines in descending order (= newest first), optionally
@@ -774,9 +794,20 @@ module Ci
variables.append(key: 'CI_MERGE_REQUEST_EVENT_TYPE', value: merge_request_event_type.to_s)
variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s)
variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s)
+
+ diff = self.merge_request_diff
+ if diff.present?
+ variables.append(key: 'CI_MERGE_REQUEST_DIFF_ID', value: diff.id.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_DIFF_BASE_SHA', value: diff.base_commit_sha)
+ end
+
variables.concat(merge_request.predefined_variables)
end
+ if Gitlab::Ci::Features.pipeline_open_merge_requests?(project) && open_merge_requests_refs.any?
+ variables.append(key: 'CI_OPEN_MERGE_REQUESTS', value: open_merge_requests_refs.join(','))
+ end
+
variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active?
variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true') if freeze_period?
@@ -824,9 +855,8 @@ module Ci
end
def execute_hooks
- data = pipeline_data
- project.execute_hooks(data, :pipeline_hooks)
- project.execute_services(data, :pipeline_hooks)
+ project.execute_hooks(pipeline_data, :pipeline_hooks) if project.has_active_hooks?(:pipeline_hooks)
+ project.execute_services(pipeline_data, :pipeline_hooks) if project.has_active_services?(:pipeline_hooks)
end
# All the merge requests for which the current pipeline runs/ran against
@@ -844,9 +874,39 @@ module Ci
all_merge_requests.order(id: :desc)
end
+ # This returns a list of MRs that point
+ # to the same source project/branch
+ def related_merge_requests
+ if merge_request?
+ # We look for all other MRs that this branch might be pointing to
+ MergeRequest.where(
+ source_project_id: merge_request.source_project_id,
+ source_branch: merge_request.source_branch)
+ else
+ MergeRequest.where(
+ source_project_id: project_id,
+ source_branch: ref)
+ end
+ end
+
+ # We cannot use `all_merge_requests`, due to race condition
+ # This returns a list of at most 4 open MRs
+ def open_merge_requests_refs
+ strong_memoize(:open_merge_requests_refs) do
+ # We ensure that triggering user can actually read the pipeline
+ related_merge_requests
+ .opened
+ .limit(MAX_OPEN_MERGE_REQUESTS_REFS)
+ .order(id: :desc)
+ .preload(:target_project)
+ .select { |mr| can?(user, :read_merge_request, mr) }
+ .map { |mr| mr.to_reference(project, full: true) }
+ end
+ end
+
def same_family_pipeline_ids
::Gitlab::Ci::PipelineObjectHierarchy.new(
- base_and_ancestors(same_project: true), options: { same_project: true }
+ self.class.where(id: root_ancestor), options: { same_project: true }
).base_and_descendants.select(:id)
end
@@ -869,6 +929,15 @@ module Ci
.base_and_descendants
end
+ def root_ancestor
+ return self unless child?
+
+ Gitlab::Ci::PipelineObjectHierarchy
+ .new(self.class.unscoped.where(id: id), options: { same_project: true })
+ .base_and_ancestors(hierarchy_order: :desc)
+ .first
+ end
+
def bridge_triggered?
source_bridge.present?
end
@@ -878,7 +947,8 @@ module Ci
end
def child?
- parent_pipeline.present?
+ parent_pipeline? && # child pipelines have `parent_pipeline` source
+ parent_pipeline.present?
end
def parent?
@@ -910,10 +980,18 @@ module Ci
builds.latest.with_reports(reports_scope)
end
+ def latest_test_report_builds
+ latest_report_builds(Ci::JobArtifact.test_reports).preload(:project)
+ end
+
def builds_with_coverage
builds.latest.with_coverage
end
+ def builds_with_failed_tests(limit: nil)
+ latest_test_report_builds.failed.limit(limit)
+ end
+
def has_reports?(reports_scope)
complete? && latest_report_builds(reports_scope).exists?
end
@@ -934,7 +1012,7 @@ module Ci
def test_reports
Gitlab::Ci::Reports::TestReports.new.tap do |test_reports|
- latest_report_builds(Ci::JobArtifact.test_reports).preload(:project).find_each do |build|
+ latest_test_report_builds.find_each do |build|
build.collect_test_reports!(test_reports)
end
end
@@ -950,12 +1028,20 @@ module Ci
def coverage_reports
Gitlab::Ci::Reports::CoverageReports.new.tap do |coverage_reports|
- latest_report_builds(Ci::JobArtifact.coverage_reports).each do |build|
+ latest_report_builds(Ci::JobArtifact.coverage_reports).includes(:project).find_each do |build|
build.collect_coverage_reports!(coverage_reports)
end
end
end
+ def codequality_reports
+ Gitlab::Ci::Reports::CodequalityReports.new.tap do |codequality_reports|
+ latest_report_builds(Ci::JobArtifact.codequality_reports).each do |build|
+ build.collect_codequality_reports!(codequality_reports)
+ end
+ end
+ end
+
def terraform_reports
::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports|
latest_report_builds(::Ci::JobArtifact.terraform_reports).each do |build|
@@ -1128,7 +1214,25 @@ module Ci
end
def pipeline_data
- Gitlab::DataBuilder::Pipeline.build(self)
+ strong_memoize(:pipeline_data) do
+ Gitlab::DataBuilder::Pipeline.build(self)
+ end
+ end
+
+ def merge_request_diff_sha
+ return unless merge_request?
+
+ if merge_request_pipeline?
+ source_sha
+ else
+ sha
+ end
+ end
+
+ def merge_request_diff
+ return unless merge_request?
+
+ merge_request.merge_request_diff_for(merge_request_diff_sha)
end
def push_details
diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb
index 5feb3b0a1e6..c58a3bab1a9 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -19,5 +19,9 @@ module Clusters
with: Gitlab::Regex.cluster_agent_name_regex,
message: Gitlab::Regex.cluster_agent_name_regex_message
}
+
+ def has_access_to?(requested_project)
+ requested_project == project
+ end
end
end
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index d1d6defb713..6f4b273a2c8 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -4,8 +4,8 @@ require 'openssl'
module Clusters
module Applications
- # DEPRECATED: This model represents the Helm 2 Tiller server, and is no longer being actively used.
- # It is being kept around for a potential cleanup of the unused Tiller server.
+ # DEPRECATED: This model represents the Helm 2 Tiller server.
+ # It is being kept around to enable the cleanup of the unused Tiller server.
class Helm < ApplicationRecord
self.table_name = 'clusters_applications_helm'
@@ -27,29 +27,11 @@ module Clusters
end
def set_initial_status
- return unless not_installable?
-
- self.status = status_states[:installable] if cluster&.platform_kubernetes_active?
- end
-
- # It can only be uninstalled if there are no other applications installed
- # or with intermitent installation statuses in the database.
- def allowed_to_uninstall?
- strong_memoize(:allowed_to_uninstall) do
- applications = nil
-
- Clusters::Cluster::APPLICATIONS.each do |application_name, klass|
- next if application_name == 'helm'
-
- extra_apps = Clusters::Applications::Helm.where('EXISTS (?)', klass.select(1).where(cluster_id: cluster_id))
-
- applications = applications ? applications.or(extra_apps) : extra_apps
- end
-
- !applications.exists?
- end
+ # The legacy Tiller server is not installable, which is the initial status of every app
end
+ # DEPRECATED: This command is only for development and testing purposes, to simulate
+ # a Helm 2 cluster with an existing Tiller server.
def install_command
Gitlab::Kubernetes::Helm::V2::InitCommand.new(
name: name,
@@ -70,13 +52,6 @@ module Clusters
ca_key.present? && ca_cert.present?
end
- def post_uninstall
- cluster.kubeclient.delete_namespace(Gitlab::Kubernetes::Helm::NAMESPACE)
- rescue Kubeclient::ResourceNotFoundError
- # we actually don't care if the namespace is not present
- # since we want to delete it anyway.
- end
-
private
def files
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 03f4caccccd..1e41b6f4f31 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.22.0'
+ VERSION = '0.23.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 3cf5542ae76..a34d8a6b98d 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -149,8 +149,8 @@ module Clusters
scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) }
scope :with_application_prometheus, -> { includes(:application_prometheus).joins(:application_prometheus) }
- scope :with_project_alert_service_data, -> (project_ids) do
- conditions = { projects: { alerts_service: [:data] } }
+ scope :with_project_http_integrations, -> (project_ids) do
+ conditions = { projects: :alert_management_http_integrations }
includes(conditions).joins(conditions).where(projects: { id: project_ids })
end
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
index b82b1887308..ad6699daa78 100644
--- a/app/models/clusters/concerns/application_core.rb
+++ b/app/models/clusters/concerns/application_core.rb
@@ -62,6 +62,14 @@ module Clusters
end
end
+ def uninstall_command
+ helm_command_module::DeleteCommand.new(
+ name: name,
+ rbac: cluster.platform_kubernetes_rbac?,
+ files: files
+ )
+ end
+
def prepare_uninstall
# Override if your application needs any action before
# being uninstalled by Helm
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
index 00aeb7669ad..a022f174faf 100644
--- a/app/models/clusters/concerns/application_data.rb
+++ b/app/models/clusters/concerns/application_data.rb
@@ -3,14 +3,6 @@
module Clusters
module Concerns
module ApplicationData
- def uninstall_command
- helm_command_module::DeleteCommand.new(
- name: name,
- rbac: cluster.platform_kubernetes_rbac?,
- files: files
- )
- end
-
def repository
nil
end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index b85a902d58b..84de5828491 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -94,9 +94,20 @@ module Clusters
return unless enabled?
pods = read_pods(environment.deployment_namespace)
+ deployments = read_deployments(environment.deployment_namespace)
- # extract_relevant_pod_data avoids uploading all the pod info into ReactiveCaching
- { pods: extract_relevant_pod_data(pods) }
+ ingresses = if ::Feature.enabled?(:canary_ingress_weight_control, environment.project, default_enabled: true)
+ read_ingresses(environment.deployment_namespace)
+ else
+ []
+ end
+
+ # extract only the data required for display to avoid unnecessary caching
+ {
+ pods: extract_relevant_pod_data(pods),
+ deployments: extract_relevant_deployment_data(deployments),
+ ingresses: extract_relevant_ingress_data(ingresses)
+ }
end
def terminals(environment, data)
@@ -109,6 +120,25 @@ module Clusters
@kubeclient ||= build_kube_client!
end
+ def rollout_status(environment, data)
+ project = environment.project
+
+ deployments = filter_by_project_environment(data[:deployments], project.full_path_slug, environment.slug)
+ pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug)
+ ingresses = data[:ingresses].presence || []
+
+ ::Gitlab::Kubernetes::RolloutStatus.from_deployments(*deployments, pods_attrs: pods, ingresses: ingresses)
+ end
+
+ def ingresses(namespace)
+ ingresses = read_ingresses(namespace)
+ ingresses.map { |ingress| ::Gitlab::Kubernetes::Ingress.new(ingress) }
+ end
+
+ def patch_ingress(namespace, ingress, data)
+ kubeclient.patch_ingress(ingress.name, data, namespace)
+ end
+
private
def default_namespace(project, environment_name:)
@@ -140,6 +170,18 @@ module Clusters
[]
end
+ def read_deployments(namespace)
+ kubeclient.get_deployments(namespace: namespace).as_json
+ rescue Kubeclient::ResourceNotFoundError
+ []
+ end
+
+ def read_ingresses(namespace)
+ kubeclient.get_ingresses(namespace: namespace).as_json
+ rescue Kubeclient::ResourceNotFoundError
+ []
+ end
+
def build_kube_client!
raise "Incomplete settings" unless api_url
@@ -231,8 +273,24 @@ module Clusters
}
end
end
+
+ def extract_relevant_deployment_data(deployments)
+ deployments.map do |deployment|
+ {
+ 'metadata' => deployment.fetch('metadata', {}).slice('name', 'generation', 'labels', 'annotations'),
+ 'spec' => deployment.fetch('spec', {}).slice('replicas'),
+ 'status' => deployment.fetch('status', {}).slice('observedGeneration')
+ }
+ end
+ end
+
+ def extract_relevant_ingress_data(ingresses)
+ ingresses.map do |ingress|
+ {
+ 'metadata' => ingress.fetch('metadata', {}).slice('name', 'labels', 'annotations')
+ }
+ end
+ end
end
end
end
-
-Clusters::Platforms::Kubernetes.prepend_if_ee('EE::Clusters::Platforms::Kubernetes')
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index 07c49ed48e6..a3ee8e4f364 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -86,9 +86,9 @@ class CommitCollection
# Batch load full Commits from the repository
# and map to a Hash of id => Commit
- replacements = Hash[unenriched.map do |c|
- [c.id, Commit.lazy(container, c.id)]
- end.compact]
+ replacements = unenriched.each_with_object({}) do |c, result|
+ result[c.id] = Commit.lazy(container, c.id)
+ end.compact
# Replace the commits, keeping the same order
@commits = @commits.map do |original_commit|
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 49fc780f372..45944401c2d 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -70,8 +70,12 @@ module CacheMarkdownField
def refresh_markdown_cache!
updates = refresh_markdown_cache
-
- save_markdown(updates)
+ if updates.present? && save_markdown(updates)
+ # save_markdown updates DB columns directly, so compute and save mentions
+ # by calling store_mentions! or we end-up with missing mentions although those
+ # would appear in the notes, descriptions, etc in the UI
+ store_mentions! if mentionable_attributes_changed?(updates)
+ end
end
def cached_html_up_to_date?(markdown_field)
@@ -106,7 +110,19 @@ module CacheMarkdownField
def updated_cached_html_for(markdown_field)
return unless cached_markdown_fields.markdown_fields.include?(markdown_field)
- refresh_markdown_cache! if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field))
+ if attribute_invalidated?(cached_markdown_fields.html_field(markdown_field))
+ # Invalidated due to Markdown content change
+ # We should not persist the updated HTML here since this will depend on whether the
+ # Markdown content change will be persisted. Both will be persisted together when the model is saved.
+ if changed_attributes.key?(markdown_field)
+ refresh_markdown_cache
+ else
+ # Invalidated due to stale HTML cache
+ # This could happen when the Markdown cache version is bumped or when a model is imported and the HTML is empty.
+ # We persist the updated HTML here so that subsequent calls to this method do not have to regenerate the HTML again.
+ refresh_markdown_cache!
+ end
+ end
cached_html_for(markdown_field)
end
@@ -140,6 +156,46 @@ module CacheMarkdownField
nil
end
+ def store_mentions!
+ refs = all_references(self.author)
+
+ references = {}
+ references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
+ references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
+ references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
+
+ # One retry is enough as next time `model_user_mention` should return the existing mention record,
+ # that threw the `ActiveRecord::RecordNotUnique` exception in first place.
+ self.class.safe_ensure_unique(retries: 1) do
+ user_mention = model_user_mention
+
+ # this may happen due to notes polymorphism, so noteable_id may point to a record
+ # that no longer exists as we cannot have FK on noteable_id
+ break if user_mention.blank?
+
+ user_mention.mentioned_users_ids = references[:mentioned_users_ids]
+ user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
+ user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]
+
+ if user_mention.has_mentions?
+ user_mention.save!
+ else
+ user_mention.destroy!
+ end
+ end
+
+ true
+ end
+
+ def mentionable_attributes_changed?(changes = saved_changes)
+ return false unless is_a?(Mentionable)
+
+ self.class.mentionable_attrs.any? do |attr|
+ changes.key?(cached_markdown_fields.html_field(attr.first)) &&
+ changes.fetch(cached_markdown_fields.html_field(attr.first)).last.present?
+ end
+ end
+
included do
cattr_reader :cached_markdown_fields do
Gitlab::MarkdownCache::FieldData.new
diff --git a/app/models/concerns/can_move_repository_storage.rb b/app/models/concerns/can_move_repository_storage.rb
new file mode 100644
index 00000000000..52c3a4106e3
--- /dev/null
+++ b/app/models/concerns/can_move_repository_storage.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module CanMoveRepositoryStorage
+ extend ActiveSupport::Concern
+
+ RepositoryReadOnlyError = Class.new(StandardError)
+
+ # Tries to set repository as read_only, checking for existing Git transfers in
+ # progress beforehand. Setting a repository read-only will fail if it is
+ # already in that state.
+ #
+ # @return nil. Failures will raise an exception
+ def set_repository_read_only!(skip_git_transfer_check: false)
+ with_lock do
+ raise RepositoryReadOnlyError, _('Git transfer in progress') if
+ !skip_git_transfer_check && git_transfer_in_progress?
+
+ raise RepositoryReadOnlyError, _('Repository already read-only') if
+ self.class.where(id: id).pick(:repository_read_only)
+
+ raise ActiveRecord::RecordNotSaved, _('Database update failed') unless
+ update_column(:repository_read_only, true)
+
+ nil
+ end
+ end
+
+ # Set repository as writable again. Unlike setting it read-only, this will
+ # succeed if the repository is already writable.
+ def set_repository_writable!
+ with_lock do
+ raise ActiveRecord::RecordNotSaved, _('Database update failed') unless
+ update_column(:repository_read_only, false)
+
+ nil
+ end
+ end
+
+ def git_transfer_in_progress?
+ reference_counter(type: repository.repo_type).value > 0
+ end
+
+ def reference_counter(type:)
+ Gitlab::ReferenceCounter.new(type.identifier_for_container(self))
+ end
+end
diff --git a/app/models/concerns/case_sensitivity.rb b/app/models/concerns/case_sensitivity.rb
index abddbf1c7e3..31b5afd604d 100644
--- a/app/models/concerns/case_sensitivity.rb
+++ b/app/models/concerns/case_sensitivity.rb
@@ -11,12 +11,14 @@ module CaseSensitivity
def iwhere(params)
criteria = self
- params.each do |key, value|
+ params.each do |column, value|
+ column = arel_table[column] unless column.is_a?(Arel::Attribute)
+
criteria = case value
when Array
- criteria.where(value_in(key, value))
+ criteria.where(value_in(column, value))
else
- criteria.where(value_equal(key, value))
+ criteria.where(value_equal(column, value))
end
end
@@ -28,7 +30,7 @@ module CaseSensitivity
def value_equal(column, value)
lower_value = lower_value(value)
- lower_column(arel_table[column]).eq(lower_value).to_sql
+ lower_column(column).eq(lower_value).to_sql
end
def value_in(column, values)
@@ -36,7 +38,7 @@ module CaseSensitivity
lower_value(value)
end
- lower_column(arel_table[column]).in(lower_values).to_sql
+ lower_column(column).in(lower_values).to_sql
end
def lower_value(value)
diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb
index bb8df37f649..e1f07fa162c 100644
--- a/app/models/concerns/enums/ci/pipeline.rb
+++ b/app/models/concerns/enums/ci/pipeline.rb
@@ -9,7 +9,8 @@ module Enums
{
unknown_failure: 0,
config_error: 1,
- external_validation_failure: 2
+ external_validation_failure: 2,
+ deployments_limit_exceeded: 23
}
end
@@ -24,8 +25,6 @@ module Enums
schedule: 4,
api: 5,
external: 6,
- # TODO: Rename `pipeline` to `cross_project_pipeline` in 13.0
- # https://gitlab.com/gitlab-org/gitlab/issues/195991
pipeline: 7,
chat: 8,
webide: 9,
@@ -53,6 +52,10 @@ module Enums
sources.except(*dangling_sources.keys)
end
+ def self.ci_branch_sources
+ ci_sources.except(:merge_request_event)
+ end
+
def self.ci_and_parent_sources
ci_sources.merge(sources.slice(:parent_pipeline))
end
diff --git a/app/models/concerns/enums/data_visualization_palette.rb b/app/models/concerns/enums/data_visualization_palette.rb
new file mode 100644
index 00000000000..25002e64ba6
--- /dev/null
+++ b/app/models/concerns/enums/data_visualization_palette.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Enums
+ # These color palettes are part of the Pajamas Design System.
+ # See https://design.gitlab.com/data-visualization/color/#categorical-data
+ module DataVisualizationPalette
+ def self.colors
+ {
+ blue: 0,
+ orange: 1,
+ aqua: 2,
+ green: 3,
+ magenta: 4
+ }
+ end
+
+ def self.weights
+ {
+ '50' => 0,
+ '100' => 1,
+ '200' => 2,
+ '300' => 3,
+ '400' => 4,
+ '500' => 5,
+ '600' => 6,
+ '700' => 7,
+ '800' => 8,
+ '900' => 9,
+ '950' => 10
+ }
+ end
+ end
+end
diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb
index f01bd60ef16..b08c05b1934 100644
--- a/app/models/concerns/enums/internal_id.rb
+++ b/app/models/concerns/enums/internal_id.rb
@@ -15,7 +15,8 @@ module Enums
operations_user_lists: 7,
alert_management_alerts: 8,
sprints: 9, # iterations
- design_management_designs: 10
+ design_management_designs: 10,
+ incident_management_oncall_schedules: 11
}
end
end
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index 3dea4a9f5fb..9692941d8b2 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -88,7 +88,7 @@ module HasRepository
group_branch_default_name = group&.default_branch_name if respond_to?(:group)
- group_branch_default_name || Gitlab::CurrentSettings.default_branch_name
+ (group_branch_default_name || Gitlab::CurrentSettings.default_branch_name).presence
end
def reload_default_branch
diff --git a/app/models/concerns/has_wiki_page_meta_attributes.rb b/app/models/concerns/has_wiki_page_meta_attributes.rb
new file mode 100644
index 00000000000..136f2d00ce3
--- /dev/null
+++ b/app/models/concerns/has_wiki_page_meta_attributes.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+module HasWikiPageMetaAttributes
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid)
+ WikiPageInvalid = Class.new(ArgumentError)
+
+ included do
+ has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
+ validates :title, length: { maximum: 255 }, allow_nil: false
+ validate :no_two_metarecords_in_same_container_can_have_same_canonical_slug
+
+ scope :with_canonical_slug, ->(slug) do
+ slug_table_name = klass.reflect_on_association(:slugs).table_name
+
+ joins(:slugs).where(slug_table_name => { canonical: true, slug: slug })
+ end
+ end
+
+ class_methods do
+ # Return the (updated) WikiPage::Meta record for a given wiki page
+ #
+ # If none is found, then a new record is created, and its fields are set
+ # to reflect the wiki_page passed.
+ #
+ # @param [String] last_known_slug
+ # @param [WikiPage] wiki_page
+ #
+ # This method raises errors on validation issues.
+ def find_or_create(last_known_slug, wiki_page)
+ raise WikiPageInvalid unless wiki_page.valid?
+
+ container = wiki_page.wiki.container
+ known_slugs = [last_known_slug, wiki_page.slug].compact.uniq
+ raise 'No slugs found! This should not be possible.' if known_slugs.empty?
+
+ transaction do
+ updates = wiki_page_updates(wiki_page)
+ found = find_by_canonical_slug(known_slugs, container)
+ meta = found || create!(updates.merge(container_attrs(container)))
+
+ meta.update_state(found.nil?, known_slugs, wiki_page, updates)
+
+ # We don't need to run validations here, since find_by_canonical_slug
+ # guarantees that there is no conflict in canonical_slug, and DB
+ # constraints on title and project_id/group_id enforce our other invariants
+ # This saves us a query.
+ meta
+ end
+ end
+
+ def find_by_canonical_slug(canonical_slug, container)
+ meta, conflict = with_canonical_slug(canonical_slug)
+ .where(container_attrs(container))
+ .limit(2)
+
+ if conflict.present?
+ meta.errors.add(:canonical_slug, 'Duplicate value found')
+ raise CanonicalSlugConflictError.new(meta)
+ end
+
+ meta
+ end
+
+ private
+
+ def wiki_page_updates(wiki_page)
+ last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc
+
+ {
+ title: wiki_page.title,
+ created_at: last_commit_date,
+ updated_at: last_commit_date
+ }
+ end
+
+ def container_key
+ raise NotImplementedError
+ end
+
+ def container_attrs(container)
+ { container_key => container.id }
+ end
+ end
+
+ def canonical_slug
+ strong_memoize(:canonical_slug) { slugs.canonical.take&.slug }
+ end
+
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ def canonical_slug=(slug)
+ return if @canonical_slug == slug
+
+ if persisted?
+ transaction do
+ slugs.canonical.update_all(canonical: false)
+ page_slug = slugs.create_with(canonical: true).find_or_create_by(slug: slug)
+ page_slug.update_columns(canonical: true) unless page_slug.canonical?
+ end
+ else
+ slugs.new(slug: slug, canonical: true)
+ end
+
+ @canonical_slug = slug
+ end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
+
+ def update_state(created, known_slugs, wiki_page, updates)
+ update_wiki_page_attributes(updates)
+ insert_slugs(known_slugs, created, wiki_page.slug)
+ self.canonical_slug = wiki_page.slug
+ end
+
+ private
+
+ def update_wiki_page_attributes(updates)
+ # Remove all unnecessary updates:
+ updates.delete(:updated_at) if updated_at == updates[:updated_at]
+ updates.delete(:created_at) if created_at <= updates[:created_at]
+ updates.delete(:title) if title == updates[:title]
+
+ update_columns(updates) unless updates.empty?
+ end
+
+ def insert_slugs(strings, is_new, canonical_slug)
+ creation = Time.current.utc
+
+ slug_attrs = strings.map do |slug|
+ slug_attributes(slug, canonical_slug, is_new, creation)
+ end
+ slugs.insert_all(slug_attrs) unless !is_new && slug_attrs.size == 1
+
+ @canonical_slug = canonical_slug if is_new || strings.size == 1 # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ def slug_attributes(slug, canonical_slug, is_new, creation)
+ {
+ slug: slug,
+ canonical: (is_new && slug == canonical_slug),
+ created_at: creation,
+ updated_at: creation
+ }.merge(slug_meta_attributes)
+ end
+
+ def slug_meta_attributes
+ { self.association(:slugs).reflection.foreign_key => id }
+ end
+
+ def no_two_metarecords_in_same_container_can_have_same_canonical_slug
+ container_id = attributes[self.class.container_key.to_s]
+
+ return unless container_id.present? && canonical_slug.present?
+
+ offending = self.class.with_canonical_slug(canonical_slug).where(self.class.container_key => container_id)
+ offending = offending.where.not(id: id) if persisted?
+
+ if offending.exists?
+ errors.add(:canonical_slug, 'each page in a wiki must have a distinct canonical slug')
+ end
+ end
+end
diff --git a/app/models/concerns/has_wiki_page_slug_attributes.rb b/app/models/concerns/has_wiki_page_slug_attributes.rb
new file mode 100644
index 00000000000..3335eccbaf6
--- /dev/null
+++ b/app/models/concerns/has_wiki_page_slug_attributes.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module HasWikiPageSlugAttributes
+ extend ActiveSupport::Concern
+
+ included do
+ validates :slug, uniqueness: { scope: meta_foreign_key }
+ validates :slug, length: { maximum: 2048 }, allow_nil: false
+ validates :canonical, uniqueness: {
+ scope: meta_foreign_key,
+ if: :canonical?,
+ message: 'Only one slug can be canonical per wiki metadata record'
+ }
+
+ scope :canonical, -> { where(canonical: true) }
+
+ def update_columns(attrs = {})
+ super(attrs.reverse_merge(updated_at: Time.current.utc))
+ end
+ end
+
+ def self.update_all(attrs = {})
+ super(attrs.reverse_merge(updated_at: Time.current.utc))
+ end
+end
diff --git a/app/models/concerns/ignorable_columns.rb b/app/models/concerns/ignorable_columns.rb
index 744a1f0b5f3..4cbcb25406d 100644
--- a/app/models/concerns/ignorable_columns.rb
+++ b/app/models/concerns/ignorable_columns.rb
@@ -31,15 +31,13 @@ module IgnorableColumns
alias_method :ignore_column, :ignore_columns
def ignored_columns_details
- unless defined?(@ignored_columns_details)
- IGNORE_COLUMN_MUTEX.synchronize do
- @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {}
- end
- end
+ return @ignored_columns_details if defined?(@ignored_columns_details)
- @ignored_columns_details
+ IGNORE_COLUMN_MONITOR.synchronize do
+ @ignored_columns_details ||= superclass.try(:ignored_columns_details)&.dup || {}
+ end
end
- IGNORE_COLUMN_MUTEX = Mutex.new
+ IGNORE_COLUMN_MONITOR = Monitor.new
end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 7624a1a4e80..c3a394c1ca5 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -84,7 +84,6 @@ module Issuable
validate :description_max_length_for_new_records_is_valid, on: :update
before_validation :truncate_description_on_import!
- after_save :store_mentions!, if: :any_mentionable_attributes_changed?
scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) }
@@ -198,7 +197,7 @@ module Issuable
end
def severity
- return IssuableSeverity::DEFAULT unless incident?
+ return IssuableSeverity::DEFAULT unless supports_severity?
issuable_severity&.severity || IssuableSeverity::DEFAULT
end
@@ -305,14 +304,12 @@ module Issuable
end
def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false)
- params = {
+ highest_priority = highest_label_priority(
target_type: name,
target_column: "#{table_name}.id",
project_column: "#{table_name}.#{project_foreign_key}",
excluded_labels: excluded_labels
- }
-
- highest_priority = highest_label_priority(params).to_sql
+ ).to_sql
# When using CTE make sure to select the same columns that are on the group_by clause.
# This prevents errors when ignored columns are present in the database.
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index b10e8547e86..5db077c178d 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -80,37 +80,6 @@ module Mentionable
all_references(current_user).users
end
- def store_mentions!
- refs = all_references(self.author)
-
- references = {}
- references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
- references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
- references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence
-
- # One retry should be enough as next time `model_user_mention` should return the existing mention record, that
- # threw the `ActiveRecord::RecordNotUnique` exception in first place.
- self.class.safe_ensure_unique(retries: 1) do
- user_mention = model_user_mention
-
- # this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists
- # as we cannot have FK on noteable_id
- break if user_mention.blank?
-
- user_mention.mentioned_users_ids = references[:mentioned_users_ids]
- user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
- user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]
-
- if user_mention.has_mentions?
- user_mention.save!
- else
- user_mention.destroy!
- end
- end
-
- true
- end
-
def referenced_users
User.where(id: user_mentions.select("unnest(mentioned_users_ids)"))
end
@@ -216,12 +185,6 @@ module Mentionable
source.select { |key, val| mentionable.include?(key) }
end
- def any_mentionable_attributes_changed?
- self.class.mentionable_attrs.any? do |attr|
- saved_changes.key?(attr.first)
- end
- end
-
# Determine whether or not a cross-reference Note has already been created between this Mentionable and
# the specified target.
def cross_reference_exists?(target)
@@ -237,12 +200,12 @@ module Mentionable
end
# User mention that is parsed from model description rather then its related notes.
- # Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
+ # Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
# Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have
# a description attribute.
#
# Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception
- # in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block.
+ # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block.
def model_user_mention
user_mentions.where(note_id: nil).first_or_initialize
end
diff --git a/app/models/concerns/optimized_issuable_label_filter.rb b/app/models/concerns/optimized_issuable_label_filter.rb
index 7be4a26d4fa..82055822cfb 100644
--- a/app/models/concerns/optimized_issuable_label_filter.rb
+++ b/app/models/concerns/optimized_issuable_label_filter.rb
@@ -1,6 +1,15 @@
# frozen_string_literal: true
module OptimizedIssuableLabelFilter
+ extend ActiveSupport::Concern
+
+ prepended do
+ extend Gitlab::Cache::RequestCache
+
+ # Avoid repeating label queries times when the finder is instantiated multiple times during the request.
+ request_cache(:find_label_ids) { [root_namespace.id, params.label_names] }
+ end
+
def by_label(items)
return items unless params.labels?
@@ -41,7 +50,7 @@ module OptimizedIssuableLabelFilter
def issuables_with_selected_labels(items, target_model)
if root_namespace
- all_label_ids = find_label_ids(root_namespace)
+ all_label_ids = find_label_ids
# Found less labels in the DB than we were searching for. Return nothing.
return items.none if all_label_ids.size != params.label_names.size
@@ -57,18 +66,20 @@ module OptimizedIssuableLabelFilter
items
end
- def find_label_ids(root_namespace)
- finder_params = {
- include_subgroups: true,
- include_ancestor_groups: true,
- include_descendant_groups: true,
- group: root_namespace,
- title: params.label_names
- }
-
- LabelsFinder
- .new(nil, finder_params)
- .execute(skip_authorization: true)
+ def find_label_ids
+ group_labels = Label
+ .where(project_id: nil)
+ .where(title: params.label_names)
+ .where(group_id: root_namespace.self_and_descendants.select(:id))
+
+ project_labels = Label
+ .where(group_id: nil)
+ .where(title: params.label_names)
+ .where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendants.select(:id)))
+
+ Label
+ .from_union([group_labels, project_labels], remove_duplicates: false)
+ .reorder(nil)
.pluck(:title, :id)
.group_by(&:first)
.values
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index b69fb2931c3..07bec07e556 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -70,6 +70,14 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:metrics_dashboard_access_level, value)
end
+ def analytics_access_level=(value)
+ write_feature_attribute_string(:analytics_access_level, value)
+ end
+
+ def operations_access_level=(value)
+ write_feature_attribute_string(:operations_access_level, value)
+ end
+
private
def write_feature_attribute_boolean(field, value)
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index cddca72f91f..65195a8d5aa 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -12,6 +12,10 @@ module ProtectedRef
delegate :matching, :matches?, :wildcard?, to: :ref_matcher
scope :for_project, ->(project) { where(project: project) }
+
+ def allow_multiple?(type)
+ false
+ end
end
def commit
@@ -29,7 +33,7 @@ module ProtectedRef
# to fail.
has_many :"#{type}_access_levels", inverse_of: self.model_name.singular
- validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }
+ validates :"#{type}_access_levels", length: { is: 1, message: "are restricted to a single instance per #{self.model_name.human}." }, unless: -> { allow_multiple?(type) }
accepts_nested_attributes_for :"#{type}_access_levels", allow_destroy: true
end
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index 28dc3366e51..5e38ce7cad8 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -45,6 +45,7 @@ module ProtectedRefAccess
end
def check_access(user)
+ return false unless user
return true if user.admin?
user.can?(:push_code, project) &&
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 3470bdab5fb..dbc70ac2218 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
# The usage of the ReactiveCaching module is documented here:
-# https://docs.gitlab.com/ee/development/reactive_caching.md
+# https://docs.gitlab.com/ee/development/reactive_caching.html
+#
module ReactiveCaching
extend ActiveSupport::Concern
diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb
new file mode 100644
index 00000000000..a45b4626628
--- /dev/null
+++ b/app/models/concerns/repository_storage_movable.rb
@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+module RepositoryStorageMovable
+ extend ActiveSupport::Concern
+ include AfterCommitQueue
+
+ included do
+ scope :order_created_at_desc, -> { order(created_at: :desc) }
+
+ validates :container, presence: true
+ validates :state, presence: true
+ validates :source_storage_name,
+ on: :create,
+ presence: true,
+ inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
+ validates :destination_storage_name,
+ on: :create,
+ presence: true,
+ inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
+ validate :container_repository_writable, on: :create
+
+ default_value_for(:destination_storage_name, allows_nil: false) do
+ pick_repository_storage
+ end
+
+ state_machine initial: :initial do
+ event :schedule do
+ transition initial: :scheduled
+ end
+
+ event :start do
+ transition scheduled: :started
+ end
+
+ event :finish_replication do
+ transition started: :replicated
+ end
+
+ event :finish_cleanup do
+ transition replicated: :finished
+ end
+
+ event :do_fail do
+ transition [:initial, :scheduled, :started] => :failed
+ transition replicated: :cleanup_failed
+ end
+
+ around_transition initial: :scheduled do |storage_move, block|
+ block.call
+
+ begin
+ storage_move.container.set_repository_read_only!(skip_git_transfer_check: true)
+ rescue => err
+ storage_move.add_error(err.message)
+ next false
+ end
+
+ storage_move.run_after_commit do
+ storage_move.schedule_repository_storage_update_worker
+ end
+
+ true
+ end
+
+ before_transition started: :replicated do |storage_move|
+ storage_move.container.set_repository_writable!
+
+ storage_move.update_repository_storage(storage_move.destination_storage_name)
+ end
+
+ before_transition started: :failed do |storage_move|
+ storage_move.container.set_repository_writable!
+ end
+
+ state :initial, value: 1
+ state :scheduled, value: 2
+ state :started, value: 3
+ state :finished, value: 4
+ state :failed, value: 5
+ state :replicated, value: 6
+ state :cleanup_failed, value: 7
+ end
+ end
+
+ class_methods do
+ private
+
+ def pick_repository_storage
+ container_klass = reflect_on_association(:container).class_name.constantize
+
+ container_klass.pick_repository_storage
+ end
+ end
+
+ # Projects, snippets, and group wikis has different db structure. In projects,
+ # we need to update some columns in this step, but we don't with the other resources.
+ #
+ # Therefore, we create this No-op method for snippets and wikis and let project
+ # overwrite it in their implementation.
+ def update_repository_storage(new_storage)
+ # No-op
+ end
+
+ def schedule_repository_storage_update_worker
+ raise NotImplementedError
+ end
+
+ def add_error(message)
+ errors.add(error_key, message)
+ end
+
+ private
+
+ def container_repository_writable
+ add_error(_('is read only')) if container&.repository_read_only?
+ end
+
+ def error_key
+ raise NotImplementedError
+ end
+end
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index c70ce9bebcc..71d8e06de76 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -4,6 +4,36 @@
# Object must have name and path db fields and respond to parent and parent_changed? methods.
module Routable
extend ActiveSupport::Concern
+ include CaseSensitivity
+
+ # Finds a Routable object by its full path, without knowing the class.
+ #
+ # Usage:
+ #
+ # Routable.find_by_full_path('groupname') # -> Group
+ # Routable.find_by_full_path('groupname/projectname') # -> Project
+ #
+ # Returns a single object, or nil.
+ def self.find_by_full_path(path, follow_redirects: false, route_scope: Route, redirect_route_scope: RedirectRoute)
+ return unless path.present?
+
+ # Case sensitive match first (it's cheaper and the usual case)
+ # If we didn't have an exact match, we perform a case insensitive search
+ #
+ # We need to qualify the columns with the table name, to support both direct lookups on
+ # Route/RedirectRoute, and scoped lookups through the Routable classes.
+ route =
+ route_scope.find_by(routes: { path: path }) ||
+ route_scope.iwhere(Route.arel_table[:path] => path).take
+
+ if follow_redirects
+ route ||= redirect_route_scope.iwhere(RedirectRoute.arel_table[:path] => path).take
+ end
+
+ return unless route
+
+ route.is_a?(Routable) ? route : route.source
+ end
included do
# Remove `inverse_of: source` when upgraded to rails 5.2
@@ -30,15 +60,14 @@ module Routable
#
# Returns a single object, or nil.
def find_by_full_path(path, follow_redirects: false)
- # Case sensitive match first (it's cheaper and the usual case)
- # If we didn't have an exact match, we perform a case insensitive search
- found = includes(:route).find_by(routes: { path: path }) || where_full_path_in([path]).take
-
- return found if found
-
- if follow_redirects
- joins(:redirect_routes).find_by("LOWER(redirect_routes.path) = LOWER(?)", path)
- end
+ # TODO: Optimize these queries by avoiding joins
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/292252
+ Routable.find_by_full_path(
+ path,
+ follow_redirects: follow_redirects,
+ route_scope: includes(:route).references(:routes),
+ redirect_route_scope: joins(:redirect_routes)
+ )
end
# Builds a relation to find multiple objects by their full paths.
diff --git a/app/models/concerns/shardable.rb b/app/models/concerns/shardable.rb
index c0883c08289..4bebb99d195 100644
--- a/app/models/concerns/shardable.rb
+++ b/app/models/concerns/shardable.rb
@@ -8,6 +8,7 @@ module Shardable
scope :for_repository_storage, -> (repository_storage) { joins(:shard).where(shards: { name: repository_storage }) }
scope :excluding_repository_storage, -> (repository_storage) { joins(:shard).where.not(shards: { name: repository_storage }) }
+ scope :for_shard, -> (shard) { where(shard_id: shard) }
validates :shard, presence: true
end
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
index 23fd73f2904..8273059b30c 100644
--- a/app/models/concerns/timebox.rb
+++ b/app/models/concerns/timebox.rb
@@ -12,10 +12,16 @@ module Timebox
include FromUnion
TimeboxStruct = Struct.new(:title, :name, :id) do
+ include GlobalID::Identification
+
# Ensure these models match the interface required for exporting
def serializable_hash(_opts = {})
{ title: title, name: name, id: id }
end
+
+ def self.declarative_policy_class
+ "TimeboxPolicy"
+ end
end
# Represents a "No Timebox" state used for filtering Issues and Merge
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index a1f83884f02..535cf25eb9d 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -57,6 +57,13 @@ module TokenAuthenticatable
token = read_attribute(token_field)
token.present? && ActiveSupport::SecurityUtils.secure_compare(other_token, token)
end
+
+ # Base strategy delegates to this method for formatting a token before
+ # calling set_token. Can be overridden in models to e.g. add a prefix
+ # to the tokens
+ mod.define_method("format_#{token_field}") do |token|
+ token
+ end
end
def token_authenticatable_module
diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb
index aafd0b538a3..f72a41f06b1 100644
--- a/app/models/concerns/token_authenticatable_strategies/base.rb
+++ b/app/models/concerns/token_authenticatable_strategies/base.rb
@@ -18,10 +18,15 @@ module TokenAuthenticatableStrategies
raise NotImplementedError
end
- def set_token(instance)
+ def set_token(instance, token)
raise NotImplementedError
end
+ # Default implementation returns the token as-is
+ def format_token(instance, token)
+ instance.send("format_#{@token_field}", token) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
def ensure_token(instance)
write_new_token(instance) unless token_set?(instance)
get_token(instance)
@@ -57,7 +62,8 @@ module TokenAuthenticatableStrategies
def write_new_token(instance)
new_token = generate_available_token
- set_token(instance, new_token)
+ formatted_token = format_token(instance, new_token)
+ set_token(instance, formatted_token)
end
def unique
diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb
index 325a5531926..473b430bb04 100644
--- a/app/models/concerns/triggerable_hooks.rb
+++ b/app/models/concerns/triggerable_hooks.rb
@@ -15,14 +15,13 @@ module TriggerableHooks
wiki_page_hooks: :wiki_page_events,
deployment_hooks: :deployment_events,
feature_flag_hooks: :feature_flag_events,
- release_hooks: :releases_events
+ release_hooks: :releases_events,
+ member_hooks: :member_events
}.freeze
extend ActiveSupport::Concern
class_methods do
- attr_reader :triggerable_hooks
-
attr_reader :triggers
def hooks_for(trigger)
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 4adbd37608f..0d7ce966537 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -25,8 +25,7 @@ class ContainerRepository < ApplicationRecord
.with_container_registry
.select(:id)
- ContainerRepository
- .joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id")
+ joins("INNER JOIN (#{project_scope.to_sql}) projects on projects.id=container_repositories.project_id")
end
scope :for_project_id, ->(project_id) { where(project_id: project_id) }
scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index ed22d4ba231..4f8f86965d7 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -17,7 +17,7 @@ class CustomEmoji < ApplicationRecord
uniqueness: { scope: [:namespace_id, :name] },
presence: true,
length: { maximum: 36 },
- format: { with: /\A([a-z0-9]+[-_]?)+[a-z0-9]+\z/ }
+ format: { with: /\A[a-z0-9][a-z0-9\-_]*[a-z0-9]\z/ }
private
diff --git a/app/models/cycle_analytics/level_base.rb b/app/models/cycle_analytics/level_base.rb
index 967de9a22b4..901636a7263 100644
--- a/app/models/cycle_analytics/level_base.rb
+++ b/app/models/cycle_analytics/level_base.rb
@@ -4,9 +4,52 @@ module CycleAnalytics
module LevelBase
STAGES = %i[issue plan code test review staging].freeze
+ # This is a temporary adapter class which makes the new value stream (cycle analytics)
+ # backend compatible with the old implementation.
+ class StageAdapter
+ def initialize(stage, options)
+ @stage = stage
+ @options = options
+ end
+
+ # rubocop: disable CodeReuse/Presenter
+ def as_json(serializer: AnalyticsStageSerializer)
+ presenter = Analytics::CycleAnalytics::StagePresenter.new(stage)
+
+ serializer.new.represent(OpenStruct.new(
+ title: presenter.title,
+ description: presenter.description,
+ legend: presenter.legend,
+ name: stage.name,
+ project_median: median,
+ group_median: median
+ ))
+ end
+ # rubocop: enable CodeReuse/Presenter
+
+ def events
+ data_collector.records_fetcher.serialized_records
+ end
+
+ def median
+ data_collector.median.seconds
+ end
+
+ alias_method :project_median, :median
+ alias_method :group_median, :median
+
+ private
+
+ attr_reader :stage, :options
+
+ def data_collector
+ @data_collector ||= Gitlab::Analytics::CycleAnalytics::DataCollector.new(stage: stage, params: options)
+ end
+ end
+
def all_medians_by_stage
STAGES.each_with_object({}) do |stage_name, medians_per_stage|
- medians_per_stage[stage_name] = self[stage_name].project_median
+ medians_per_stage[stage_name] = self[stage_name].median
end
end
@@ -16,12 +59,16 @@ module CycleAnalytics
end
end
- def no_stats?
- stats.all? { |hash| hash[:value].nil? }
+ def [](stage_name)
+ if Feature.enabled?(:new_project_level_vsa_backend, resource_parent, default_enabled: true)
+ StageAdapter.new(build_stage(stage_name), options)
+ else
+ Gitlab::CycleAnalytics::Stage[stage_name].new(options: options)
+ end
end
- def [](stage_name)
- Gitlab::CycleAnalytics::Stage[stage_name].new(options: options)
+ def stage_params_by_name(name)
+ Gitlab::Analytics::CycleAnalytics::DefaultStages.find_by_name!(name)
end
end
end
diff --git a/app/models/cycle_analytics/project_level.rb b/app/models/cycle_analytics/project_level.rb
index 591435baf34..26cdcc0db4b 100644
--- a/app/models/cycle_analytics/project_level.rb
+++ b/app/models/cycle_analytics/project_level.rb
@@ -20,5 +20,14 @@ module CycleAnalytics
def permissions(user:)
Gitlab::CycleAnalytics::Permissions.get(user: user, project: project)
end
+
+ def build_stage(stage_name)
+ stage_params = stage_params_by_name(stage_name).merge(project: project)
+ Analytics::CycleAnalytics::ProjectStage.new(stage_params)
+ end
+
+ def resource_parent
+ project
+ end
end
end
diff --git a/app/models/dependency_proxy.rb b/app/models/dependency_proxy.rb
index 510a304ff17..9cbaf7e9884 100644
--- a/app/models/dependency_proxy.rb
+++ b/app/models/dependency_proxy.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
module DependencyProxy
+ URL_SUFFIX = '/dependency_proxy/containers'
+
def self.table_name_prefix
'dependency_proxy_'
end
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
new file mode 100644
index 00000000000..f3c7f34e0d7
--- /dev/null
+++ b/app/models/dependency_proxy/manifest.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class DependencyProxy::Manifest < ApplicationRecord
+ include FileStoreMounter
+
+ belongs_to :group
+
+ validates :group, presence: true
+ validates :file, presence: true
+ validates :file_name, presence: true
+ validates :digest, presence: true
+
+ mount_file_store_uploader DependencyProxy::FileUploader
+
+ scope :find_or_initialize_by_file_name, ->(file_name) { find_or_initialize_by(file_name: file_name) }
+end
diff --git a/app/models/dependency_proxy/registry.rb b/app/models/dependency_proxy/registry.rb
index 471d5be2600..6492acf325a 100644
--- a/app/models/dependency_proxy/registry.rb
+++ b/app/models/dependency_proxy/registry.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
class DependencyProxy::Registry
- AUTH_URL = 'https://auth.docker.io'.freeze
- LIBRARY_URL = 'https://registry-1.docker.io/v2'.freeze
+ AUTH_URL = 'https://auth.docker.io'
+ LIBRARY_URL = 'https://registry-1.docker.io/v2'
+ PROXY_AUTH_URL = Gitlab::Utils.append_path(Gitlab.config.gitlab.url, "jwt/auth")
class << self
def auth_url(image)
@@ -17,6 +18,10 @@ class DependencyProxy::Registry
"#{LIBRARY_URL}/#{image_path(image)}/blobs/#{blob_sha}"
end
+ def authenticate_header
+ "Bearer realm=\"#{PROXY_AUTH_URL}\",service=\"#{::Auth::DependencyProxyAuthenticationService::AUDIENCE}\""
+ end
+
private
def image_path(image)
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 36ac1bdb236..b93b714ec8b 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -37,12 +37,19 @@ class Deployment < ApplicationRecord
end
scope :for_status, -> (status) { where(status: status) }
+ scope :for_project, -> (project_id) { where(project_id: project_id) }
scope :visible, -> { where(status: %i[running success failed canceled]) }
scope :stoppable, -> { where.not(on_stop: nil).where.not(deployable_id: nil).success }
scope :active, -> { where(status: %i[created running]) }
- scope :older_than, -> (deployment) { where('id < ?', deployment.id) }
- scope :with_deployable, -> { includes(:deployable).where('deployable_id IS NOT NULL') }
+ 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 :finished_between, -> (start_date, end_date = nil) do
+ selected = where('deployments.finished_at >= ?', start_date)
+ selected = selected.where('deployments.finished_at < ?', end_date) if end_date
+ selected
+ end
FINISHED_STATUSES = %i[success failed canceled].freeze
@@ -63,6 +70,10 @@ class Deployment < ApplicationRecord
transition any - [:canceled] => :canceled
end
+ event :skip do
+ transition any - [:skipped] => :skipped
+ end
+
before_transition any => FINISHED_STATUSES do |deployment|
deployment.finished_at = Time.current
end
@@ -105,7 +116,8 @@ class Deployment < ApplicationRecord
running: 1,
success: 2,
failed: 3,
- canceled: 4
+ canceled: 4,
+ skipped: 5
}
def self.last_for_environment(environment)
@@ -144,6 +156,10 @@ class Deployment < ApplicationRecord
project.repository.delete_refs(*ref_paths.flatten)
end
end
+
+ def latest_for_sha(sha)
+ where(sha: sha).order(id: :desc).take
+ end
end
def commit
@@ -297,6 +313,8 @@ class Deployment < ApplicationRecord
drop
when 'canceled'
cancel
+ when 'skipped'
+ skip
else
raise ArgumentError, "The status #{status.inspect} is invalid"
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 4b2e62bf761..944a64f5419 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -19,7 +19,7 @@ class DiffNote < Note
# EE might have added a type when the module was prepended
validates :noteable_type, inclusion: { in: -> (_note) { noteable_types } }
validate :positions_complete
- validate :verify_supported
+ validate :verify_supported, unless: :importing?
before_validation :set_line_code, if: :on_text?, unless: :importing?
after_save :keep_around_commits, unless: :importing?
@@ -149,7 +149,7 @@ class DiffNote < Note
end
def supported?
- for_commit? || for_design? || self.noteable.has_complete_diff_refs?
+ for_commit? || for_design? || self.noteable&.has_complete_diff_refs?
end
def set_line_code
diff --git a/app/models/environment.rb b/app/models/environment.rb
index deded3eeae0..31a95bb1b5d 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -32,6 +32,7 @@ class Environment < ApplicationRecord
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 :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
before_validation :nullify_external_url
@@ -60,6 +61,7 @@ class Environment < ApplicationRecord
addressable_url: true
delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true
+ delegate :auto_rollback_enabled?, to: :project
scope :available, -> { with_state(:available) }
scope :stopped, -> { with_state(:stopped) }
@@ -240,10 +242,6 @@ class Environment < ApplicationRecord
def cancel_deployment_jobs!
jobs = active_deployments.with_deployable
jobs.each do |deployment|
- # guard against data integrity issues,
- # for example https://gitlab.com/gitlab-org/gitlab/-/issues/218659#note_348823660
- next unless deployment.deployable
-
Gitlab::OptimisticLocking.retry_lock(deployment.deployable) do |deployable|
deployable.cancel! if deployable&.cancelable?
end
@@ -387,8 +385,38 @@ class Environment < ApplicationRecord
!!deployment_platform&.cluster&.application_elastic_stack_available?
end
+ def rollout_status
+ return unless rollout_status_available?
+
+ result = rollout_status_with_reactive_cache
+
+ result || ::Gitlab::Kubernetes::RolloutStatus.loading
+ end
+
+ def ingresses
+ return unless rollout_status_available?
+
+ deployment_platform.ingresses(deployment_namespace)
+ end
+
+ def patch_ingress(ingress, data)
+ return unless rollout_status_available?
+
+ deployment_platform.patch_ingress(deployment_namespace, ingress, data)
+ end
+
private
+ def rollout_status_available?
+ has_terminals?
+ end
+
+ def rollout_status_with_reactive_cache
+ with_reactive_cache do |data|
+ deployment_platform.rollout_status(self, data)
+ end
+ end
+
def has_metrics_and_can_query?
has_metrics? && prometheus_adapter.can_query?
end
@@ -396,11 +424,6 @@ class Environment < ApplicationRecord
def generate_slug
self.slug = Gitlab::Slug::Environment.new(name).generate
end
-
- # Overrides ReactiveCaching default to activate limit checking behind a FF
- def reactive_cache_limit_enabled?
- Feature.enabled?(:reactive_caching_limit_environment, project, default_enabled: true)
- end
end
Environment.prepend_if_ee('EE::Environment')
diff --git a/app/models/experiment.rb b/app/models/experiment.rb
index f179a1fc6ce..a4cacab25ee 100644
--- a/app/models/experiment.rb
+++ b/app/models/experiment.rb
@@ -2,17 +2,24 @@
class Experiment < ApplicationRecord
has_many :experiment_users
+ has_many :experiment_subjects, inverse_of: :experiment
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
- def self.add_user(name, group_type, user)
- return unless experiment = find_or_create_by(name: name)
+ def self.add_user(name, group_type, user, context = {})
+ find_or_create_by!(name: name).record_user_and_group(user, group_type, context)
+ end
- experiment.record_user_and_group(user, group_type)
+ def self.record_conversion_event(name, user)
+ find_or_create_by!(name: name).record_conversion_event_for_user(user)
end
# Create or update the recorded experiment_user row for the user in this experiment.
- def record_user_and_group(user, group_type)
- experiment_users.find_or_initialize_by(user: user).update!(group_type: group_type)
+ def record_user_and_group(user, group_type, context = {})
+ experiment_users.find_or_initialize_by(user: user).update!(group_type: group_type, context: context)
+ end
+
+ def record_conversion_event_for_user(user)
+ experiment_users.find_by(user: user, converted_at: nil)&.touch(:converted_at)
end
end
diff --git a/app/models/experiment_subject.rb b/app/models/experiment_subject.rb
new file mode 100644
index 00000000000..51ffc0b304e
--- /dev/null
+++ b/app/models/experiment_subject.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class ExperimentSubject < ApplicationRecord
+ include ::Gitlab::Experimentation::GroupTypes
+
+ belongs_to :experiment, inverse_of: :experiment_subjects
+ belongs_to :user
+ belongs_to :group
+ belongs_to :project
+
+ validates :experiment, presence: true
+ validates :variant, presence: true
+ validate :must_have_one_subject_present
+
+ enum variant: { GROUP_CONTROL => 0, GROUP_EXPERIMENTAL => 1 }
+
+ private
+
+ def must_have_one_subject_present
+ if non_nil_subjects.length != 1
+ errors.add(:base, s_("ExperimentSubject|Must have exactly one of User, Group, or Project."))
+ end
+ end
+
+ def non_nil_subjects
+ @non_nil_subjects ||= [user, group, project].reject(&:blank?)
+ end
+end
diff --git a/app/models/exported_protected_branch.rb b/app/models/exported_protected_branch.rb
new file mode 100644
index 00000000000..6e8abbc2389
--- /dev/null
+++ b/app/models/exported_protected_branch.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ExportedProtectedBranch < ProtectedBranch
+ has_many :push_access_levels, -> { where(deploy_key_id: nil) }, class_name: "ProtectedBranch::PushAccessLevel", foreign_key: :protected_branch_id
+end
diff --git a/app/models/group.rb b/app/models/group.rb
index 3509299a579..739135e82dd 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -73,6 +73,7 @@ class Group < Namespace
has_one :dependency_proxy_setting, class_name: 'DependencyProxy::GroupSetting'
has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob'
+ has_many :dependency_proxy_manifests, class_name: 'DependencyProxy::Manifest'
accepts_nested_attributes_for :variables, allow_destroy: true
@@ -402,6 +403,13 @@ class Group < Namespace
.where(source_id: self_and_hierarchy.reorder(nil).select(:id))
end
+ def direct_and_indirect_members_with_inactive
+ GroupMember
+ .non_request
+ .non_invite
+ .where(source_id: self_and_hierarchy.reorder(nil).select(:id))
+ end
+
def users_with_parents
User
.where(id: members_with_parents.select(:user_id))
@@ -428,6 +436,20 @@ class Group < Namespace
])
end
+ # Returns all users (also inactive) that are members of the group because:
+ # 1. They belong to the group
+ # 2. They belong to a project that belongs to the group
+ # 3. They belong to a sub-group or project in such sub-group
+ # 4. They belong to an ancestor group
+ def direct_and_indirect_users_with_inactive
+ User.from_union([
+ User
+ .where(id: direct_and_indirect_members_with_inactive.select(:user_id))
+ .reorder(nil),
+ project_users_with_descendants
+ ])
+ end
+
def users_count
members.count
end
diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb
index 89602e40357..c47ae3a80ba 100644
--- a/app/models/group_import_state.rb
+++ b/app/models/group_import_state.rb
@@ -3,6 +3,8 @@
class GroupImportState < ApplicationRecord
self.primary_key = :group_id
+ MAX_ERROR_LENGTH = 255
+
belongs_to :group, inverse_of: :import_state
belongs_to :user, optional: false
@@ -30,7 +32,7 @@ class GroupImportState < ApplicationRecord
after_transition any => :failed do |state, transition|
last_error = transition.args.first
- state.update_column(:last_error, last_error) if last_error
+ state.update_column(:last_error, last_error.truncate(MAX_ERROR_LENGTH)) if last_error
end
end
diff --git a/app/models/identity.rb b/app/models/identity.rb
index 40d9f856abf..fc97c68b756 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -18,6 +18,9 @@ class Identity < ApplicationRecord
scope :with_extern_uid, ->(provider, extern_uid) do
iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider)
end
+ scope :with_any_extern_uid, ->(provider) do
+ where.not(extern_uid: nil).with_provider(provider)
+ end
def ldap?
Gitlab::Auth::OAuth::Provider.ldap_provider?(provider)
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 7dc18cacd7c..253f4465cd9 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -22,6 +22,7 @@ class Issue < ApplicationRecord
include Presentable
include IssueAvailableFeatures
include Todoable
+ include FromUnion
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -90,6 +91,8 @@ class Issue < ApplicationRecord
alias_attribute :parent_ids, :project_id
alias_method :issuing_parent, :project
+ alias_attribute :external_author, :service_desk_reply_to
+
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) }
@@ -306,6 +309,7 @@ class Issue < ApplicationRecord
!moved? && persisted? &&
user.can?(:admin_issue, self.project)
end
+ alias_method :can_clone?, :can_move?
def to_branch_name
if self.confidential?
@@ -328,7 +332,9 @@ class Issue < ApplicationRecord
related_issues = ::Issue
.select(['issues.*', 'issue_links.id AS issue_link_id',
'issue_links.link_type as issue_link_type_value',
- 'issue_links.target_id as issue_link_source_id'])
+ 'issue_links.target_id as issue_link_source_id',
+ 'issue_links.created_at as issue_link_created_at',
+ 'issue_links.updated_at as issue_link_updated_at'])
.joins("INNER JOIN issue_links ON
(issue_links.source_id = issues.id AND issue_links.target_id = #{id})
OR
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
index ba7cd973e9d..7a35bb1cd1f 100644
--- a/app/models/iteration.rb
+++ b/app/models/iteration.rb
@@ -32,9 +32,9 @@ class Iteration < ApplicationRecord
scope :closed, -> { with_state(:closed) }
scope :within_timeframe, -> (start_date, end_date) do
- where('start_date is not NULL or due_date is not NULL')
- .where('start_date is NULL or start_date <= ?', end_date)
- .where('due_date is NULL or due_date >= ?', start_date)
+ where('start_date IS NOT NULL OR due_date IS NOT NULL')
+ .where('start_date IS NULL OR start_date <= ?', end_date)
+ .where('due_date IS NULL OR due_date >= ?', start_date)
end
scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) }
diff --git a/app/models/label.rb b/app/models/label.rb
index 3c70eef9bd5..54129c7c7f3 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -257,7 +257,7 @@ class Label < ApplicationRecord
end
def present(attributes)
- super(attributes.merge(presenter_class: ::LabelPresenter))
+ super(**attributes.merge(presenter_class: ::LabelPresenter))
end
private
diff --git a/app/models/label_priority.rb b/app/models/label_priority.rb
index 8f8f36efbfe..11854404a71 100644
--- a/app/models/label_priority.rb
+++ b/app/models/label_priority.rb
@@ -1,10 +1,13 @@
# frozen_string_literal: true
class LabelPriority < ApplicationRecord
+ include Importable
+
belongs_to :project
belongs_to :label
- validates :project, :label, :priority, presence: true
+ validates :label, presence: true, unless: :importing?
+ validates :project, :priority, presence: true
validates :label_id, uniqueness: { scope: :project_id }
validates :priority, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
diff --git a/app/models/list.rb b/app/models/list.rb
index ec211dfd497..1df565c83e6 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -7,7 +7,7 @@ class List < ApplicationRecord
belongs_to :label
has_many :list_user_preferences
- enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 }
+ enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4, iteration: 5 }
validates :board, :list_type, presence: true, unless: :importing?
validates :label, :position, presence: true, if: :label?
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index d379f85bc15..043f07cf9f3 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -233,13 +233,13 @@ class MergeRequest < ApplicationRecord
cannot_be_merged_rechecking? ? 'checking' : merge_status
end
- validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_without_fork?]
+ validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?]
validates :source_branch, presence: true
validates :target_project, presence: true
validates :target_branch, presence: true
validates :merge_user, presence: true, if: :auto_merge_enabled?, unless: :importing?
- validate :validate_branches, unless: [:allow_broken, :importing?, :closed_without_fork?]
- validate :validate_fork, unless: :closed_without_fork?
+ validate :validate_branches, unless: [:allow_broken, :importing?, :closed_or_merged_without_fork?]
+ validate :validate_fork, unless: :closed_or_merged_without_fork?
validate :validate_target_project, on: :create
scope :by_source_or_target_branch, ->(branch_name) do
@@ -274,7 +274,7 @@ class MergeRequest < ApplicationRecord
scope :with_api_entity_associations, -> {
preload_routables
.preload(:assignees, :author, :unresolved_notes, :labels, :milestone,
- :timelogs, :latest_merge_request_diff,
+ :timelogs, :latest_merge_request_diff, :reviewers,
target_project: :project_feature,
metrics: [:latest_closed_by, :merged_by])
}
@@ -314,6 +314,38 @@ class MergeRequest < ApplicationRecord
scope :with_jira_issue_keys, -> { where('title ~ :regex OR merge_requests.description ~ :regex', regex: Gitlab::Regex.jira_issue_key_regex.source) }
+ scope :review_requested, -> do
+ where(reviewers_subquery.exists)
+ end
+
+ scope :no_review_requested, -> do
+ where(reviewers_subquery.exists.not)
+ end
+
+ scope :review_requested_to, ->(user) do
+ where(
+ reviewers_subquery
+ .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user))
+ .exists
+ )
+ end
+
+ scope :no_review_requested_to, ->(user) do
+ where(
+ reviewers_subquery
+ .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user))
+ .exists
+ .not
+ )
+ end
+
+ def self.total_time_to_merge
+ join_metrics
+ .merge(MergeRequest::Metrics.with_valid_time_to_merge)
+ .pluck(MergeRequest::Metrics.time_to_merge_expression)
+ .first
+ end
+
after_save :keep_around_commit, unless: :importing?
alias_attribute :project, :target_project
@@ -361,6 +393,12 @@ class MergeRequest < ApplicationRecord
end
end
+ def self.reviewers_subquery
+ MergeRequestReviewer.arel_table
+ .project('true')
+ .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id"))
+ end
+
def rebase_in_progress?
rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid)
end
@@ -845,8 +883,8 @@ class MergeRequest < ApplicationRecord
!!merge_jid && !merged? && Gitlab::SidekiqStatus.running?(merge_jid)
end
- def closed_without_fork?
- closed? && source_project_missing?
+ def closed_or_merged_without_fork?
+ (closed? || merged?) && source_project_missing?
end
def source_project_missing?
@@ -941,7 +979,7 @@ class MergeRequest < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def diffable_merge_ref?
- merge_ref_head.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?)
+ open? && merge_ref_head.present? && (Feature.enabled?(:display_merge_conflicts_in_diff, project) || can_be_merged?)
end
# Returns boolean indicating the merge_status should be rechecked in order to
@@ -1423,6 +1461,20 @@ class MergeRequest < ApplicationRecord
compare_reports(Ci::GenerateCoverageReportsService)
end
+ def has_codequality_reports?
+ return false unless Feature.enabled?(:codequality_mr_diff, project)
+
+ actual_head_pipeline&.has_reports?(Ci::JobArtifact.codequality_reports)
+ end
+
+ def compare_codequality_reports
+ unless has_codequality_reports?
+ return { status: :error, status_reason: _('This merge request does not have codequality reports') }
+ end
+
+ compare_reports(Ci::CompareCodequalityReportsService)
+ end
+
def find_terraform_reports
unless has_terraform_reports?
return { status: :error, status_reason: 'This merge request does not have terraform reports' }
@@ -1703,7 +1755,7 @@ class MergeRequest < ApplicationRecord
end
def allows_reviewers?
- Feature.enabled?(:merge_request_reviewers, project)
+ Feature.enabled?(:merge_request_reviewers, project, default_enabled: true)
end
def allows_multiple_reviewers?
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index 66bff3f5982..d3fe256fb1b 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -10,6 +10,11 @@ class MergeRequest::Metrics < ApplicationRecord
scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) }
scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date)) }
+ scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) }
+
+ def self.time_to_merge_expression
+ Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))')
+ end
private
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 24809141570..d23e66b9697 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -358,6 +358,7 @@ class MergeRequestDiff < ApplicationRecord
if comparison
comparison.diffs_in_batch(batch_page, batch_size, diff_options: diff_options)
else
+ reorder_diff_files!
diffs_in_batch_collection(batch_page, batch_size, diff_options: diff_options)
end
end
@@ -371,6 +372,7 @@ class MergeRequestDiff < ApplicationRecord
if comparison
comparison.diffs(diff_options)
else
+ reorder_diff_files!
diffs_collection(diff_options)
end
end
@@ -565,7 +567,7 @@ class MergeRequestDiff < ApplicationRecord
end
def build_merge_request_diff_files(diffs)
- diffs.map.with_index do |diff, index|
+ sort_diffs(diffs).map.with_index do |diff, index|
diff_hash = diff.to_hash.merge(
binary: false,
merge_request_diff_id: self.id,
@@ -678,6 +680,7 @@ class MergeRequestDiff < ApplicationRecord
rows = build_merge_request_diff_files(diff_collection)
create_merge_request_diff_files(rows)
+ new_attributes[:sorted] = true
self.class.uncached { merge_request_diff_files.reset }
end
@@ -719,6 +722,35 @@ class MergeRequestDiff < ApplicationRecord
repo.keep_around(start_commit_sha, head_commit_sha, base_commit_sha)
end
end
+
+ def reorder_diff_files!
+ return unless sort_diffs?
+ return if sorted? || merge_request_diff_files.empty?
+
+ diff_files = sort_diffs(merge_request_diff_files)
+
+ diff_files.each_with_index do |diff_file, index|
+ diff_file.relative_order = index
+ end
+
+ transaction do
+ # The `merge_request_diff_files` table doesn't have an `id` column so
+ # we cannot use `Gitlab::Database::BulkUpdate`.
+ MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all
+ MergeRequestDiffFile.bulk_insert!(diff_files)
+ update_column(:sorted, true)
+ end
+ end
+
+ def sort_diffs(diffs)
+ return diffs unless sort_diffs?
+
+ Gitlab::Diff::FileCollectionSorter.new(diffs).sort
+ end
+
+ def sort_diffs?
+ Feature.enabled?(:sort_diffs, project, default_enabled: false)
+ end
end
MergeRequestDiff.prepend_if_ee('EE::MergeRequestDiff')
diff --git a/app/models/merge_request_reviewer.rb b/app/models/merge_request_reviewer.rb
index 1cb49c0cd76..c4e5274f832 100644
--- a/app/models/merge_request_reviewer.rb
+++ b/app/models/merge_request_reviewer.rb
@@ -2,5 +2,5 @@
class MergeRequestReviewer < ApplicationRecord
belongs_to :merge_request
- belongs_to :reviewer, class_name: "User", foreign_key: :user_id, inverse_of: :merge_request_assignees
+ belongs_to :reviewer, class_name: 'User', foreign_key: :user_id, inverse_of: :merge_request_reviewers
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index c8776be5e4a..c244150e7a3 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -9,6 +9,10 @@ class Milestone < ApplicationRecord
prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
+ class Predefined
+ ALL = [::Timebox::None, ::Timebox::Any, ::Timebox::Started, ::Timebox::Upcoming].freeze
+ end
+
has_many :milestone_releases
has_many :releases, through: :milestone_releases
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 232d0a6b05d..238e8f70778 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -28,6 +28,7 @@ class Namespace < ApplicationRecord
has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace'
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
+ has_many :namespace_onboarding_actions
# This should _not_ be `inverse_of: :namespace`, because that would also set
# `user.namespace` when this user creates a group with themselves as `owner`.
diff --git a/app/models/namespace_onboarding_action.rb b/app/models/namespace_onboarding_action.rb
new file mode 100644
index 00000000000..43dd872673c
--- /dev/null
+++ b/app/models/namespace_onboarding_action.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class NamespaceOnboardingAction < ApplicationRecord
+ belongs_to :namespace, optional: false
+
+ validates :action, presence: true
+
+ ACTIONS = {
+ subscription_created: 1,
+ git_write: 2,
+ merge_request_created: 3,
+ git_read: 4,
+ user_added: 6
+ }.freeze
+
+ enum action: ACTIONS
+
+ class << self
+ def completed?(namespace, action)
+ where(namespace: namespace, action: action).exists?
+ end
+
+ def create_action(namespace, action)
+ NamespaceOnboardingAction.safe_find_or_create_by(namespace: namespace, action: action)
+ end
+ end
+end
diff --git a/app/models/note.rb b/app/models/note.rb
index cfdac6c432f..77f7726079c 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -145,7 +145,6 @@ class Note < ApplicationRecord
after_save :expire_etag_cache, unless: :importing?
after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache
- after_save :store_mentions!, if: :any_mentionable_attributes_changed?
after_commit :notify_after_create, on: :create
after_commit :notify_after_destroy, on: :destroy
@@ -548,8 +547,8 @@ class Note < ApplicationRecord
private
- # Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception
- # in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block.
+ # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception
+ # in a multi-threaded environment. Make sure to use it within a *safe_ensure_unique* block.
def model_user_mention
return if user_mentions.is_a?(ActiveRecord::NullRelation)
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index 6066046a722..82e39e4f207 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -30,6 +30,7 @@ class NotificationSetting < ApplicationRecord
scope :preload_source_route, -> { preload(source: [:route]) }
+ # NOTE: Applicable unfound_translations.rb also needs to be updated when below events are changed.
EMAIL_EVENTS = [
:new_release,
:new_note,
@@ -51,7 +52,6 @@ class NotificationSetting < ApplicationRecord
:moved_project
].freeze
- # Update unfound_translations.rb when events are changed
def self.email_events(source = nil)
EMAIL_EVENTS
end
diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb
index 959c94931ec..13da82d16d3 100644
--- a/app/models/packages/event.rb
+++ b/app/models/packages/event.rb
@@ -25,7 +25,7 @@ class Packages::Event < ApplicationRecord
enum originator_type: { user: 0, deploy_token: 1, guest: 2 }
def self.allowed_event_name(event_scope, event_type, originator)
- return unless event_allowed?(event_scope, event_type, originator)
+ return unless event_allowed?(event_type)
# remove `package` from the event name to avoid issues with HLLRedisCounter class parsing
"i_package_#{event_scope}_#{originator}_#{event_type.gsub(/_packages?/, "")}"
@@ -33,8 +33,7 @@ class Packages::Event < ApplicationRecord
# Remove some of the events, for now, so we don't hammer Redis too hard.
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/280770
- def self.event_allowed?(event_scope, event_type, originator)
- return false if originator.to_sym == :guest
+ def self.event_allowed?(event_type)
return true if UNIQUE_EVENTS_ALLOWED.include?(event_type.to_sym)
false
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 60aab0a7222..10c98f03804 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -28,7 +28,7 @@ class Packages::Package < ApplicationRecord
validates :project, presence: true
validates :name, presence: true
- validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? }
+ validates :name, format: { with: Gitlab::Regex.package_name_regex }, unless: -> { conan? || generic? || debian? }
validates :name,
uniqueness: { scope: %i[project_id version package_type] }, unless: :conan?
@@ -40,6 +40,8 @@ class Packages::Package < ApplicationRecord
validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic?
validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget?
+ validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package?
+ validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming?
validates :version, format: { with: Gitlab::Regex.nuget_version_regex }, if: :nuget?
validates :version, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan?
validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? }
@@ -51,6 +53,11 @@ class Packages::Package < ApplicationRecord
presence: true,
format: { with: Gitlab::Regex.generic_package_version_regex },
if: :generic?
+ validates :version,
+ presence: true,
+ format: { with: Gitlab::Regex.debian_version_regex },
+ if: :debian_package?
+ validate :forbidden_debian_changes, if: :debian?
enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7, golang: 8, debian: 9 }
@@ -184,6 +191,14 @@ class Packages::Package < ApplicationRecord
tags.pluck(:name)
end
+ def debian_incoming?
+ debian? && version.nil?
+ end
+
+ def debian_package?
+ debian? && !version.nil?
+ end
+
private
def composer_tag_version?
@@ -228,4 +243,13 @@ class Packages::Package < ApplicationRecord
errors.add(:base, _('Package already exists'))
end
end
+
+ def forbidden_debian_changes
+ return unless persisted?
+
+ # Debian incoming
+ if version_was.nil? || version.nil?
+ errors.add(:version, _('cannot be changed')) if version_changed?
+ end
+ end
end
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index d68f75140ac..e8d1dd1e8c4 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Packages::PackageFile < ApplicationRecord
include UpdateProjectStatistics
+ include FileStoreMounter
delegate :project, :project_id, to: :package
delegate :conan_file_type, to: :conan_file_metadatum
@@ -35,20 +36,12 @@ class Packages::PackageFile < ApplicationRecord
.where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference })
end
- mount_uploader :file, Packages::PackageFileUploader
-
- after_save :update_file_metadata, if: :saved_change_to_file?
+ mount_file_store_uploader Packages::PackageFileUploader
update_project_statistics project_statistics_name: :packages_size
before_save :update_size_from_file
- def update_file_metadata
- # The file.object_store is set during `uploader.store!`
- # which happens after object is inserted/updated
- self.update_column(:file_store, file.object_store)
- end
-
def download_path
Gitlab::Routing.url_helpers.download_project_package_file_path(project, self)
end
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index 9855731778f..84928468ad1 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -2,6 +2,8 @@
module Pages
class LookupPath
+ include Gitlab::Utils::StrongMemoize
+
def initialize(project, trim_prefix: nil, domain: nil)
@project = project
@domain = domain
@@ -37,37 +39,28 @@ module Pages
attr_reader :project, :trim_prefix, :domain
- def artifacts_archive
- return unless Feature.enabled?(:pages_serve_from_artifacts_archive, project)
-
- project.pages_metadatum.artifacts_archive
- end
-
def deployment
- return unless Feature.enabled?(:pages_serve_from_deployments, project)
+ strong_memoize(:deployment) do
+ next unless Feature.enabled?(:pages_serve_from_deployments, project, default_enabled: true)
- project.pages_metadatum.pages_deployment
+ project.pages_metadatum.pages_deployment
+ end
end
def zip_source
- source = deployment || artifacts_archive
-
- return unless source&.file
-
- return if source.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project)
+ return unless deployment&.file
- # artifacts archive doesn't support this
- file_count = source.file_count if source.respond_to?(:file_count)
+ return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project)
- global_id = ::Gitlab::GlobalId.build(source, id: source.id).to_s
+ global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s
{
type: 'zip',
- path: source.file.url_or_file_path(expire_at: 1.day.from_now),
+ path: deployment.file.url_or_file_path(expire_at: 1.day.from_now),
global_id: global_id,
- sha256: source.file_sha256,
- file_size: source.size,
- file_count: file_count
+ sha256: deployment.file_sha256,
+ file_size: deployment.size,
+ file_count: deployment.file_count
}
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 8192310ddfb..4004ea9a662 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -34,10 +34,10 @@ class PagesDomain < ApplicationRecord
validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? }
validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? }
- default_value_for(:auto_ssl_enabled, allow_nil: false) { ::Gitlab::LetsEncrypt.enabled? }
- default_value_for :scope, allow_nil: false, value: :project
- default_value_for :wildcard, allow_nil: false, value: false
- default_value_for :usage, allow_nil: false, value: :pages
+ default_value_for(:auto_ssl_enabled, allows_nil: false) { ::Gitlab::LetsEncrypt.enabled? }
+ default_value_for :scope, allows_nil: false, value: :project
+ default_value_for :wildcard, allows_nil: false, value: false
+ default_value_for :usage, allows_nil: false, value: :pages
attr_encrypted :key,
mode: :per_attribute_iv_and_salt,
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 5aa5f2c842b..3b07551fe05 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -9,7 +9,9 @@ class PersonalAccessToken < ApplicationRecord
add_authentication_token_field :token, digest: true
REDIS_EXPIRY_TIME = 3.minutes
- TOKEN_LENGTH = 20
+
+ # PATs are 20 characters + optional configurable settings prefix (0..20)
+ TOKEN_LENGTH_RANGE = (20..40).freeze
serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize
@@ -77,6 +79,15 @@ class PersonalAccessToken < ApplicationRecord
)
end
+ def self.token_prefix
+ Gitlab::CurrentSettings.current_application_settings.personal_access_token_prefix
+ end
+
+ override :format_token
+ def format_token(token)
+ "#{self.class.token_prefix}#{token}"
+ end
+
protected
def validate_scopes
diff --git a/app/models/project.rb b/app/models/project.rb
index ebd8e56246d..daa5605c2e0 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -19,6 +19,7 @@ class Project < ApplicationRecord
include Presentable
include HasRepository
include HasWiki
+ include CanMoveRepositoryStorage
include Routable
include GroupDescendant
include Gitlab::SQL::Pattern
@@ -64,6 +65,8 @@ class Project < ApplicationRecord
SORTING_PREFERENCE_FIELD = :projects_sort
MAX_BUILD_TIMEOUT = 1.month
+ GL_REPOSITORY_TYPES = [Gitlab::GlRepository::PROJECT, Gitlab::GlRepository::WIKI, Gitlab::GlRepository::DESIGN].freeze
+
cache_markdown_field :description, pipeline: :description
default_value_for :packages_enabled, true
@@ -145,6 +148,7 @@ class Project < ApplicationRecord
# Project services
has_one :alerts_service
has_one :campfire_service
+ has_one :datadog_service
has_one :discord_service
has_one :drone_ci_service
has_one :emails_on_push_service
@@ -164,6 +168,7 @@ class Project < ApplicationRecord
has_one :bamboo_service
has_one :teamcity_service
has_one :pushover_service
+ has_one :jenkins_service
has_one :jira_service
has_one :redmine_service
has_one :youtrack_service
@@ -222,6 +227,7 @@ class Project < ApplicationRecord
has_many :snippets, class_name: 'ProjectSnippet'
has_many :hooks, class_name: 'ProjectHook'
has_many :protected_branches
+ has_many :exported_protected_branches
has_many :protected_tags
has_many :repository_languages, -> { order "share DESC" }
has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design'
@@ -336,7 +342,7 @@ class Project < ApplicationRecord
has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult'
- has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove'
+ has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove', inverse_of: :container
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :reviews, inverse_of: :project
@@ -379,11 +385,11 @@ class Project < ApplicationRecord
delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
:merge_requests_enabled?, :forking_enabled?, :issues_enabled?,
- :pages_enabled?, :snippets_enabled?, :public_pages?, :private_pages?,
+ :pages_enabled?, :analytics_enabled?, :snippets_enabled?, :public_pages?, :private_pages?,
:merge_requests_access_level, :forking_access_level, :issues_access_level,
:wiki_access_level, :snippets_access_level, :builds_access_level,
- :repository_access_level, :pages_access_level, :metrics_dashboard_access_level,
- to: :project_feature, allow_nil: true
+ :repository_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level,
+ :operations_enabled?, :operations_access_level, to: :project_feature, allow_nil: true
delegate :show_default_award_emojis, :show_default_award_emojis=,
:show_default_award_emojis?,
to: :project_setting, allow_nil: true
@@ -404,7 +410,7 @@ class Project < ApplicationRecord
delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings, prefix: :ci
delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?,
- :allow_merge_on_skipped_pipeline=, :has_confluence?,
+ :allow_merge_on_skipped_pipeline=, :has_confluence?, :allow_editing_commit_messages?,
to: :project_setting
delegate :active?, to: :prometheus_service, allow_nil: true, prefix: true
@@ -1349,6 +1355,8 @@ class Project < ApplicationRecord
end
def disabled_services
+ return ['datadog'] unless Feature.enabled?(:datadog_ci_integration, self)
+
[]
end
@@ -1836,6 +1844,7 @@ class Project < ApplicationRecord
wiki.repository.expire_content_cache
DetectRepositoryLanguagesWorker.perform_async(id)
+ ProjectCacheWorker.perform_async(self.id, [], [:repository_size])
# The import assigns iid values on its own, e.g. by re-using GitHub ids.
# Flush existing InternalId records for this project for consistency reasons.
@@ -1952,6 +1961,7 @@ class Project < ApplicationRecord
.concat(predefined_project_variables)
.concat(pages_variables)
.concat(container_registry_variables)
+ .concat(dependency_proxy_variables)
.concat(auto_devops_variables)
.concat(api_variables)
end
@@ -2003,6 +2013,18 @@ class Project < ApplicationRecord
end
end
+ def dependency_proxy_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless Gitlab.config.dependency_proxy.enabled
+
+ variables.append(key: 'CI_DEPENDENCY_PROXY_SERVER', value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}")
+ variables.append(
+ key: 'CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX',
+ value: "#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/#{namespace.root_ancestor.path}#{DependencyProxy::URL_SUFFIX}"
+ )
+ end
+ end
+
def container_registry_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
break variables unless Gitlab.config.registry.enabled
@@ -2091,39 +2113,6 @@ class Project < ApplicationRecord
(auto_devops || build_auto_devops)&.predefined_variables
end
- RepositoryReadOnlyError = Class.new(StandardError)
-
- # Tries to set repository as read_only, checking for existing Git transfers in
- # progress beforehand. Setting a repository read-only will fail if it is
- # already in that state.
- #
- # @return nil. Failures will raise an exception
- def set_repository_read_only!
- with_lock do
- raise RepositoryReadOnlyError, _('Git transfer in progress') if
- git_transfer_in_progress?
-
- raise RepositoryReadOnlyError, _('Repository already read-only') if
- self.class.where(id: id).pick(:repository_read_only)
-
- raise ActiveRecord::RecordNotSaved, _('Database update failed') unless
- update_column(:repository_read_only, true)
-
- nil
- end
- end
-
- # Set repository as writable again. Unlike setting it read-only, this will
- # succeed if the repository is already writable.
- def set_repository_writable!
- with_lock do
- raise ActiveRecord::RecordNotSaved, _('Database update failed') unless
- update_column(:repository_read_only, false)
-
- nil
- end
- end
-
def pushes_since_gc
Gitlab::Redis::SharedState.with { |redis| redis.get(pushes_since_gc_redis_shared_state_key).to_i }
end
@@ -2273,8 +2262,11 @@ class Project < ApplicationRecord
end
end
+ override :git_transfer_in_progress?
def git_transfer_in_progress?
- repo_reference_count > 0 || wiki_reference_count > 0
+ GL_REPOSITORY_TYPES.any? do |type|
+ reference_counter(type: type).value > 0
+ end
end
def storage_version=(value)
@@ -2283,10 +2275,6 @@ class Project < ApplicationRecord
@storage = nil if storage_version_changed?
end
- def reference_counter(type: Gitlab::GlRepository::PROJECT)
- Gitlab::ReferenceCounter.new(type.identifier_for_container(self))
- end
-
def badges
return project_badges unless group
@@ -2498,8 +2486,7 @@ class Project < ApplicationRecord
end
def service_desk_custom_address
- return unless ::Gitlab::ServiceDeskEmail.enabled?
- return unless ::Feature.enabled?(:service_desk_custom_address, self)
+ return unless service_desk_custom_address_enabled?
key = service_desk_setting&.project_key
return unless key.present?
@@ -2507,6 +2494,10 @@ class Project < ApplicationRecord
::Gitlab::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}")
end
+ def service_desk_custom_address_enabled?
+ ::Gitlab::ServiceDeskEmail.enabled? && ::Feature.enabled?(:service_desk_custom_address, self, default_enabled: true)
+ end
+
def root_namespace
if namespace.has_parent?
namespace.root_ancestor
@@ -2607,14 +2598,6 @@ class Project < ApplicationRecord
end
end
- def repo_reference_count
- reference_counter.value
- end
-
- def wiki_reference_count
- reference_counter(type: Gitlab::GlRepository::WIKI).value
- end
-
def check_repository_absence!
return if skip_disk_validation
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index b3ebcbd4b17..7b204cfb1c0 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -3,7 +3,7 @@
class ProjectFeature < ApplicationRecord
include Featurable
- FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze
+ FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard analytics operations).freeze
set_available_features(FEATURES)
@@ -44,7 +44,9 @@ class ProjectFeature < ApplicationRecord
default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
default_value_for :repository_access_level, value: ENABLED, allows_nil: false
+ default_value_for :analytics_access_level, value: ENABLED, allows_nil: false
default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false
+ default_value_for :operations_access_level, value: ENABLED, allows_nil: false
default_value_for(:pages_access_level, allows_nil: false) do |feature|
if ::Gitlab::Pages.access_control_is_forced?
diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb
index 092efabd73f..a9cef16f3ac 100644
--- a/app/models/project_repository.rb
+++ b/app/models/project_repository.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class ProjectRepository < ApplicationRecord
+ include EachBatch
include Shardable
belongs_to :project, inverse_of: :project_repository
diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb
index 3429dbe3a85..1e3782a1fb5 100644
--- a/app/models/project_repository_storage_move.rb
+++ b/app/models/project_repository_storage_move.rb
@@ -4,100 +4,31 @@
# project. For example, moving a project to another gitaly node to help
# balance storage capacity.
class ProjectRepositoryStorageMove < ApplicationRecord
- include AfterCommitQueue
+ extend ::Gitlab::Utils::Override
+ include RepositoryStorageMovable
- belongs_to :project, inverse_of: :repository_storage_moves
+ belongs_to :container, class_name: 'Project', inverse_of: :repository_storage_moves, foreign_key: :project_id
+ alias_attribute :project, :container
+ scope :with_projects, -> { includes(container: :route) }
- validates :project, presence: true
- validates :state, presence: true
- validates :source_storage_name,
- on: :create,
- presence: true,
- inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
- validates :destination_storage_name,
- on: :create,
- presence: true,
- inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
- validate :project_repository_writable, on: :create
-
- default_value_for(:destination_storage_name, allows_nil: false) do
- pick_repository_storage
- end
-
- state_machine initial: :initial do
- event :schedule do
- transition initial: :scheduled
- end
-
- event :start do
- transition scheduled: :started
- end
-
- event :finish_replication do
- transition started: :replicated
- end
-
- event :finish_cleanup do
- transition replicated: :finished
- end
-
- event :do_fail do
- transition [:initial, :scheduled, :started] => :failed
- transition replicated: :cleanup_failed
- end
-
- around_transition initial: :scheduled do |storage_move, block|
- block.call
-
- begin
- storage_move.project.set_repository_read_only!
- rescue => err
- errors.add(:project, err.message)
- next false
- end
-
- storage_move.run_after_commit do
- ProjectUpdateRepositoryStorageWorker.perform_async(
- storage_move.project_id,
- storage_move.destination_storage_name,
- storage_move.id
- )
- end
-
- true
- end
-
- before_transition started: :replicated do |storage_move|
- storage_move.project.set_repository_writable!
-
- storage_move.project.update_column(:repository_storage, storage_move.destination_storage_name)
- end
-
- before_transition started: :failed do |storage_move|
- storage_move.project.set_repository_writable!
- end
-
- state :initial, value: 1
- state :scheduled, value: 2
- state :started, value: 3
- state :finished, value: 4
- state :failed, value: 5
- state :replicated, value: 6
- state :cleanup_failed, value: 7
+ override :update_repository_storage
+ def update_repository_storage(new_storage)
+ container.update_column(:repository_storage, new_storage)
end
- scope :order_created_at_desc, -> { order(created_at: :desc) }
- scope :with_projects, -> { includes(project: :route) }
-
- class << self
- def pick_repository_storage
- Project.pick_repository_storage
- end
+ override :schedule_repository_storage_update_worker
+ def schedule_repository_storage_update_worker
+ ProjectUpdateRepositoryStorageWorker.perform_async(
+ project_id,
+ destination_storage_name,
+ id
+ )
end
private
- def project_repository_writable
- errors.add(:project, _('is read only')) if project&.repository_read_only?
+ override :error_key
+ def error_key
+ :project
end
end
diff --git a/app/models/project_services/datadog_service.rb b/app/models/project_services/datadog_service.rb
new file mode 100644
index 00000000000..543843ab1b0
--- /dev/null
+++ b/app/models/project_services/datadog_service.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+class DatadogService < Service
+ DEFAULT_SITE = 'datadoghq.com'.freeze
+ URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_site}/v1/input/'.freeze
+ URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_site}/account/settings#api'.freeze
+ URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_SITE}/account_management/api-app-keys/".freeze
+
+ SUPPORTED_EVENTS = %w[
+ pipeline job
+ ].freeze
+
+ prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env
+
+ with_options presence: true, if: :activated? do
+ validates :api_key, format: { with: /\A\w+\z/ }
+ validates :datadog_site, format: { with: /\A[\w\.]+\z/ }, unless: :api_url
+ validates :api_url, public_url: true, unless: :datadog_site
+ end
+
+ after_save :compose_service_hook, if: :activated?
+
+ def self.supported_events
+ SUPPORTED_EVENTS
+ end
+
+ def self.default_test_event
+ 'pipeline'
+ end
+
+ def configurable_events
+ [] # do not allow to opt out of required hooks
+ end
+
+ def title
+ 'Datadog'
+ end
+
+ def description
+ 'Trace your GitLab pipelines with Datadog'
+ end
+
+ def help
+ nil
+ # Maybe adding something in the future
+ # We could link to static help pages as well
+ # [More information](#{Gitlab::Routing.url_helpers.help_page_url('integration/datadog')})"
+ end
+
+ def self.to_param
+ 'datadog'
+ end
+
+ def fields
+ [
+ {
+ type: 'text', name: 'datadog_site',
+ placeholder: DEFAULT_SITE, default: DEFAULT_SITE,
+ help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site',
+ required: false
+ },
+ {
+ type: 'text', name: 'api_url', title: 'Custom URL',
+ help: '(Advanced) Define the full URL for your Datadog site directly',
+ required: false
+ },
+ {
+ type: 'password', name: 'api_key', title: 'API key',
+ help: "<a href=\"#{api_keys_url}\" target=\"_blank\">API key</a> used for authentication with Datadog",
+ required: true
+ },
+ {
+ type: 'text', name: 'datadog_service', title: 'Service', placeholder: 'gitlab-ci',
+ help: 'Name of this GitLab instance that all data will be tagged with'
+ },
+ {
+ type: 'text', name: 'datadog_env', title: 'Env',
+ help: 'The environment tag that traces will be tagged with'
+ }
+ ]
+ end
+
+ def compose_service_hook
+ hook = service_hook || build_service_hook
+ hook.url = hook_url
+ hook.save
+ end
+
+ def hook_url
+ url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site)
+ url = URI.parse(url)
+ url.path = File.join(url.path || '/', api_key)
+ query = { service: datadog_service, env: datadog_env }.compact
+ url.query = query.to_query unless query.empty?
+ url.to_s
+ end
+
+ def api_keys_url
+ return URL_API_KEYS_DOCS unless datadog_site.presence
+
+ sprintf(URL_TEMPLATE_API_KEYS, datadog_site: datadog_site)
+ end
+
+ def execute(data)
+ return if project.disabled_services.include?(to_param)
+
+ object_kind = data[:object_kind]
+ object_kind = 'job' if object_kind == 'build'
+ return unless supported_events.include?(object_kind)
+
+ service_hook.execute(data, "#{object_kind} hook")
+ end
+
+ def test(data)
+ begin
+ result = execute(data)
+ return { success: false, result: result[:message] } if result[:http_status] != 200
+ rescue StandardError => error
+ return { success: false, result: error }
+ end
+
+ { success: true, result: result[:message] }
+ end
+end
diff --git a/app/models/project_services/jenkins_service.rb b/app/models/project_services/jenkins_service.rb
new file mode 100644
index 00000000000..63ecfc66877
--- /dev/null
+++ b/app/models/project_services/jenkins_service.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+class JenkinsService < CiService
+ prop_accessor :jenkins_url, :project_name, :username, :password
+
+ before_update :reset_password
+
+ validates :jenkins_url, presence: true, addressable_url: true, if: :activated?
+ validates :project_name, presence: true, if: :activated?
+ validates :username, presence: true, if: ->(service) { service.activated? && service.password_touched? && service.password.present? }
+
+ default_value_for :push_events, true
+ default_value_for :merge_requests_events, false
+ default_value_for :tag_push_events, false
+
+ after_save :compose_service_hook, if: :activated?
+
+ def reset_password
+ # don't reset the password if a new one is provided
+ if (jenkins_url_changed? || username.blank?) && !password_touched?
+ self.password = nil
+ end
+ end
+
+ def compose_service_hook
+ hook = service_hook || build_service_hook
+ hook.url = hook_url
+ hook.save
+ 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")
+ end
+
+ def test(data)
+ begin
+ result = execute(data)
+ return { success: false, result: result[:message] } if result[:http_status] != 200
+ rescue StandardError => error
+ return { success: false, result: error }
+ end
+
+ { success: true, result: result[:message] }
+ end
+
+ def hook_url
+ url = URI.parse(jenkins_url)
+ url.path = File.join(url.path || '/', "project/#{project_name}")
+ url.user = ERB::Util.url_encode(username) unless username.blank?
+ url.password = ERB::Util.url_encode(password) unless password.blank?
+ url.to_s
+ end
+
+ def self.supported_events
+ %w(push merge_request tag_push)
+ end
+
+ def title
+ 'Jenkins CI'
+ end
+
+ def description
+ 'An extendable open source continuous integration 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')})"
+ end
+
+ def self.to_param
+ 'jenkins'
+ end
+
+ def fields
+ [
+ {
+ type: 'text', name: 'jenkins_url',
+ placeholder: 'Jenkins URL like http://jenkins.example.com'
+ },
+ {
+ type: 'text', name: 'project_name', placeholder: 'Project Name',
+ help: 'The URL-friendly project name. Example: my_project_name'
+ },
+ { type: 'text', name: 'username' },
+ { type: 'password', name: 'password' }
+ ]
+ end
+end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 7814bdb7106..1f4abfc1aca 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -122,12 +122,15 @@ 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|Transition ID(s)'), placeholder: s_('JiraService|Use , or ; to separate multiple transition IDs') }
+ { 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 }) }
]
end
diff --git a/app/models/project_services/mock_deployment_service.rb b/app/models/project_services/mock_deployment_service.rb
index f80819de9fb..e55335d9aae 100644
--- a/app/models/project_services/mock_deployment_service.rb
+++ b/app/models/project_services/mock_deployment_service.rb
@@ -1,5 +1,9 @@
# frozen_string_literal: true
+# Deprecated, to be deleted in 13.8 (https://gitlab.com/gitlab-org/gitlab/-/issues/293914)
+#
+# This was a class used only in development environment but became unusable
+# since DeploymentService was deleted
class MockDeploymentService < Service
default_value_for :category, 'deployment'
@@ -32,5 +36,3 @@ class MockDeploymentService < Service
false
end
end
-
-MockDeploymentService.prepend_if_ee('EE::MockDeploymentService')
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index c11b2f7cc65..8af4cd952c9 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -40,6 +40,10 @@ class PipelinesEmailService < Service
%w[pipeline]
end
+ def self.default_test_event
+ 'pipeline'
+ end
+
def execute(data, force: false)
return unless supported_events.include?(data[:object_kind])
return unless force || should_pipeline_be_notified?(data)
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index c11a7fea1c6..7605ef54d5b 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -73,8 +73,6 @@ class ProjectStatistics < ApplicationRecord
end
def update_uploads_size
- return uploads_size unless Feature.enabled?(:count_uploads_size_in_storage_stats, project)
-
self.uploads_size = project.uploads.sum(:size)
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 599c174ddd7..ad418a47476 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -48,6 +48,10 @@ class ProtectedBranch < ApplicationRecord
where(fuzzy_arel_match(:name, query.downcase))
end
+
+ def allow_multiple?(type)
+ type == :push
+ end
end
ProtectedBranch.prepend_if_ee('EE::ProtectedBranch')
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index 63d577a4866..f28440f2444 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -18,6 +18,14 @@ class ProtectedBranch::PushAccessLevel < ApplicationRecord
end
end
+ def check_access(user)
+ if Feature.enabled?(:deploy_keys_on_protected_branches, project) && user && deploy_key.present?
+ return true if user.can?(:read_project, project) && enabled_deploy_key_for_user?(deploy_key, user)
+ end
+
+ super
+ end
+
private
def validate_deploy_key_membership
@@ -27,4 +35,8 @@ class ProtectedBranch::PushAccessLevel < ApplicationRecord
self.errors.add(:deploy_key, 'is not enabled for this project')
end
end
+
+ def enabled_deploy_key_for_user?(deploy_key, user)
+ deploy_key.user_id == user.id && DeployKey.with_write_access_for_project(protected_branch.project, deploy_key: deploy_key).any?
+ end
end
diff --git a/app/models/raw_usage_data.rb b/app/models/raw_usage_data.rb
index 18cee55d06e..06cd4ad3f6c 100644
--- a/app/models/raw_usage_data.rb
+++ b/app/models/raw_usage_data.rb
@@ -5,6 +5,6 @@ class RawUsageData < ApplicationRecord
validates :recorded_at, presence: true, uniqueness: true
def update_sent_at!
- self.update_column(:sent_at, Time.current) if Feature.enabled?(:save_raw_usage_data)
+ self.update_column(:sent_at, Time.current)
end
end
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index 22f60802257..749f4a87818 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class RedirectRoute < ApplicationRecord
+ include CaseSensitivity
+
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
diff --git a/app/models/release.rb b/app/models/release.rb
index c56df0a6aa3..bebf91fb247 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -29,6 +29,8 @@ class Release < ApplicationRecord
scope :preloaded, -> { includes(:evidences, :milestones, project: [:project_feature, :route, { namespace: :route }]) }
scope :with_project_and_namespace, -> { includes(project: :namespace) }
scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) }
+ scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) }
+ scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) }
# Sorting
scope :order_created, -> { reorder('created_at ASC') }
diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb
new file mode 100644
index 00000000000..1efba6380e9
--- /dev/null
+++ b/app/models/release_highlight.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+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}")
+
+ Rails.cache.fetch(key, expires_in: CACHE_DURATION) do
+ items = self.load_items(page: page)
+
+ next if items.nil?
+
+ QueryResult.new(items: items, next_page: next_page(current_page: page))
+ end
+ end
+
+ def self.load_items(page:)
+ index = page - 1
+ file_path = file_paths[index]
+
+ return if file_path.nil?
+
+ file = File.read(file_path)
+ items = YAML.safe_load(file, permitted_classes: [Date])
+
+ platform = Gitlab.com? ? 'gitlab-com' : 'self-managed'
+
+ items&.map! do |item|
+ next unless item[platform]
+
+ begin
+ item.tap {|i| i['body'] = Kramdown::Document.new(i['body']).to_html }
+ rescue => e
+ Gitlab::ErrorTracking.track_exception(e, file_path: file_path)
+
+ next
+ end
+ end
+
+ items&.compact
+ rescue Psych::Exception => e
+ Gitlab::ErrorTracking.track_exception(e, file_path: file_path)
+
+ nil
+ end
+
+ def self.file_paths
+ @file_paths ||= Rails.cache.fetch(self.cache_key('file_paths'), expires_in: CACHE_DURATION) do
+ Dir.glob(FILES_PATH).sort.reverse
+ end
+ end
+
+ def self.cache_key(key)
+ ['release_highlight', key, Gitlab.revision].join(':')
+ end
+
+ def self.next_page(current_page: 1)
+ next_page = current_page + 1
+ next_index = next_page - 1
+
+ next_page if self.file_paths[next_index]
+ end
+
+ def self.most_recent_item_count
+ key = self.cache_key('recent_item_count')
+
+ Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do
+ self.paginated&.items&.count
+ end
+ end
+
+ def self.versions
+ key = self.cache_key('versions')
+
+ 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
+
+ versions.uniq
+ end
+ end
+
+ QueryResult = Struct.new(:items, :next_page, keyword_init: true) do
+ include Enumerable
+
+ delegate :each, to: :items
+ end
+end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index d4fd202b966..93f22dbe122 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -513,6 +513,9 @@ class Repository
# Don't attempt to return a special result if there is no blob at all
return unless blob
+ # Don't attempt to return a special result if this can't be a README
+ return blob unless Gitlab::FileDetector.type_of(blob.name) == :readme
+
# Don't attempt to return a special result unless we're looking at HEAD
return blob unless head_commit&.sha == sha
@@ -615,7 +618,7 @@ class Repository
end
def readme_path
- readme&.path
+ head_tree&.readme_path
end
cache_method :readme_path
diff --git a/app/models/resource_event.rb b/app/models/resource_event.rb
index 26dcda2630a..54fa4137f73 100644
--- a/app/models/resource_event.rb
+++ b/app/models/resource_event.rb
@@ -30,14 +30,6 @@ class ResourceEvent < ApplicationRecord
return true if issuable_count == 1
- # if none of issuable IDs is set, check explicitly if nested issuable
- # object is set, this is used during project import
- if issuable_count == 0 && importing?
- issuable_count = self.class.issuable_attrs.count { |attr| self.public_send(attr) } # rubocop:disable GitlabSecurity/PublicSend
-
- return true if issuable_count == 1
- end
-
errors.add(
:base, _("Exactly one of %{attributes} is required") %
{ attributes: self.class.issuable_attrs.join(', ') }
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index 18e2944a9ca..57a3b568c53 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -12,10 +12,9 @@ class ResourceLabelEvent < ResourceEvent
scope :inc_relations, -> { includes(:label, :user) }
validates :label, presence: { unless: :importing? }, on: :create
- validate :exactly_one_issuable
+ validate :exactly_one_issuable, unless: :importing?
after_save :expire_etag_cache
- after_save :usage_metrics
after_destroy :expire_etag_cache
enum action: {
@@ -114,16 +113,6 @@ class ResourceLabelEvent < ResourceEvent
def discussion_id_key
[self.class.name, created_at, user_id]
end
-
- def for_issue?
- issue_id.present?
- end
-
- def usage_metrics
- return unless for_issue?
-
- Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user)
- end
end
ResourceLabelEvent.prepend_if_ee('EE::ResourceLabelEvent')
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
index 6475633868a..73eb4987143 100644
--- a/app/models/resource_state_event.rb
+++ b/app/models/resource_state_event.rb
@@ -11,7 +11,7 @@ class ResourceStateEvent < ResourceEvent
# state is used for issue and merge request states.
enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5)
- after_save :usage_metrics
+ after_create :issue_usage_metrics
def self.issuable_attrs
%i(issue merge_request).freeze
@@ -27,7 +27,7 @@ class ResourceStateEvent < ResourceEvent
private
- def usage_metrics
+ def issue_usage_metrics
return unless for_issue?
case state
diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb
index ac164783945..71077758b69 100644
--- a/app/models/resource_timebox_event.rb
+++ b/app/models/resource_timebox_event.rb
@@ -13,7 +13,7 @@ class ResourceTimeboxEvent < ResourceEvent
remove: 2
}
- after_save :usage_metrics
+ after_create :issue_usage_metrics
def self.issuable_attrs
%i(issue merge_request).freeze
@@ -25,7 +25,13 @@ class ResourceTimeboxEvent < ResourceEvent
private
- def usage_metrics
+ def for_issue?
+ issue_id.present?
+ end
+
+ def issue_usage_metrics
+ return unless for_issue?
+
case self
when ResourceMilestoneEvent
Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_milestone_changed_action(author: user)
diff --git a/app/models/route.rb b/app/models/route.rb
index fe4846b3be5..fcc8459d6e5 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -4,7 +4,7 @@ class Route < ApplicationRecord
include CaseSensitivity
include Gitlab::SQL::Pattern
- belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
+ belongs_to :source, polymorphic: true, inverse_of: :route # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
validates :path,
diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb
index 30f4026e633..fec1a55f17d 100644
--- a/app/models/sentry_issue.rb
+++ b/app/models/sentry_issue.rb
@@ -1,9 +1,12 @@
# frozen_string_literal: true
class SentryIssue < ApplicationRecord
+ include Importable
+
belongs_to :issue
- validates :issue, uniqueness: true, presence: true
+ validates :issue, uniqueness: true
+ validates :issue, presence: true, unless: :importing?
validates :sentry_issue_identifier, presence: true
validate :ensure_sentry_issue_identifier_is_unique_per_project
diff --git a/app/models/service.rb b/app/models/service.rb
index 2b6971954e3..57c099d6f04 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -11,15 +11,20 @@ class Service < ApplicationRecord
include EachBatch
SERVICE_NAMES = %w[
- alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord
+ 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
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
+ alerts
+ ].freeze
+
# Fake services to help with local development.
DEV_SERVICE_NAMES = %w[
- mock_ci mock_deployment mock_monitoring
+ mock_ci mock_monitoring
].freeze
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
@@ -66,6 +71,7 @@ class Service < ApplicationRecord
scope :by_type, -> (type) { where(type: type) }
scope :by_active_flag, -> (flag) { where(active: flag) }
scope :inherit_from_id, -> (id) { where(inherit_from_id: id) }
+ scope :inherit, -> { where.not(inherit_from_id: nil) }
scope :for_group, -> (group) { where(group_id: group, type: available_services_types(include_project_specific: false)) }
scope :for_template, -> { where(template: true, type: available_services_types(include_project_specific: false)) }
scope :for_instance, -> { where(instance: true, type: available_services_types(include_project_specific: false)) }
@@ -147,6 +153,10 @@ class Service < ApplicationRecord
%w[commit push tag_push issue confidential_issue merge_request wiki_page]
end
+ def self.default_test_event
+ 'push'
+ end
+
def self.event_description(event)
ServicesHelper.service_event_description(event)
end
@@ -212,7 +222,7 @@ class Service < ApplicationRecord
end
def self.project_specific_services_names
- []
+ PROJECT_SPECIFIC_SERVICE_NAMES
end
def self.available_services_types(include_project_specific: true, include_dev: true)
@@ -270,7 +280,7 @@ class Service < ApplicationRecord
active.where(instance: true),
active.where(group_id: group_ids, inherit_from_id: nil)
]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], services.group_id), instance DESC")).group_by(&:type).each do |type, records|
- build_from_integration(records.first, association => scope.id).save!
+ build_from_integration(records.first, association => scope.id).save
end
end
@@ -386,6 +396,10 @@ class Service < ApplicationRecord
self.class.supported_events
end
+ def default_test_event
+ self.class.default_test_event
+ end
+
def execute(data)
# implement inside child
end
@@ -402,6 +416,10 @@ class Service < ApplicationRecord
!instance? && !group_id
end
+ def parent
+ project || group
+ end
+
# Returns a hash of the properties that have been assigned a new value since last save,
# indicating their original values (attr => original value).
# ActiveRecord does not provide a mechanism to track changes in serialized keys,
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index dc370b46bda..817f9d014eb 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -15,6 +15,7 @@ class Snippet < ApplicationRecord
include FromUnion
include IgnorableColumns
include HasRepository
+ include CanMoveRepositoryStorage
include AfterCommitQueue
extend ::Gitlab::Utils::Override
@@ -43,6 +44,7 @@ class Snippet < ApplicationRecord
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_one :snippet_repository, inverse_of: :snippet
+ has_many :repository_storage_moves, class_name: 'SnippetRepositoryStorageMove', inverse_of: :container
# We need to add the `dependent` in order to call the after_destroy callback
has_one :statistics, class_name: 'SnippetStatistics', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -69,7 +71,6 @@ class Snippet < ApplicationRecord
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
- after_save :store_mentions!, if: :any_mentionable_attributes_changed?
after_create :create_statistics
# Scopes
@@ -213,7 +214,8 @@ class Snippet < ApplicationRecord
def blobs
return [] unless repository_exists?
- repository.ls_files(default_branch).map { |file| Blob.lazy(repository, default_branch, file) }
+ branch = default_branch
+ list_files(branch).map { |file| Blob.lazy(repository, branch, file) }
end
def hook_attrs
diff --git a/app/models/snippet_blob.rb b/app/models/snippet_blob.rb
index cf1ab089829..bad24cc45f6 100644
--- a/app/models/snippet_blob.rb
+++ b/app/models/snippet_blob.rb
@@ -21,6 +21,10 @@ class SnippetBlob
data.bytesize
end
+ def commit_id
+ nil
+ end
+
def data
snippet.content
end
diff --git a/app/models/snippet_repository_storage_move.rb b/app/models/snippet_repository_storage_move.rb
new file mode 100644
index 00000000000..a365569bfa8
--- /dev/null
+++ b/app/models/snippet_repository_storage_move.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+# SnippetRepositoryStorageMove are details of repository storage moves for a
+# snippet. For example, moving a snippet to another gitaly node to help
+# balance storage capacity.
+class SnippetRepositoryStorageMove < ApplicationRecord
+ extend ::Gitlab::Utils::Override
+ include RepositoryStorageMovable
+
+ belongs_to :container, class_name: 'Snippet', inverse_of: :repository_storage_moves, foreign_key: :snippet_id
+ alias_attribute :snippet, :container
+
+ override :schedule_repository_storage_update_worker
+ def schedule_repository_storage_update_worker
+ # TODO https://gitlab.com/gitlab-org/gitlab/-/issues/218991
+ end
+
+ private
+
+ override :error_key
+ def error_key
+ :snippet
+ end
+end
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
index 8c72bd5ae7e..ff564d87449 100644
--- a/app/models/suggestion.rb
+++ b/app/models/suggestion.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
class Suggestion < ApplicationRecord
+ include Importable
include Suggestible
belongs_to :note, inverse_of: :suggestions
- validates :note, presence: true
+ validates :note, presence: true, unless: :importing?
validates :commit_id, presence: true, if: :applied?
delegate :position, :noteable, to: :note
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 0ddf2c5fbcd..20107147b4f 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class SystemNoteMetadata < ApplicationRecord
+ include Importable
+
# These notes's action text might contain a reference that is external.
# We should always force a deep validation upon references that are found
# in this note type.
@@ -12,18 +14,19 @@ class SystemNoteMetadata < ApplicationRecord
moved merge
label milestone
relate unrelate
+ cloned
].freeze
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
designs_added designs_modified designs_removed designs_discussion_added
- title time_tracking branch milestone discussion task moved
+ title time_tracking branch milestone discussion task moved cloned
opened closed merged duplicate locked unlocked outdated reviewer
tag due_date pinned_embed cherry_pick health_status approved unapproved
status alert_issue_added relate unrelate new_alert_added severity
].freeze
- validates :note, presence: true
+ validates :note, presence: true, unless: :importing?
validates :action, inclusion: { in: :icon_types }, allow_nil: true
belongs_to :note
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index d329b429c9d..1b99f310e1a 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -3,13 +3,6 @@
module Terraform
class State < ApplicationRecord
include UsageStatistics
- include FileStoreMounter
- include IgnorableColumns
- # These columns are being removed since geo replication falls to the versioned state
- # Tracking in https://gitlab.com/gitlab-org/gitlab/-/issues/258262
- ignore_columns %i[verification_failure verification_retry_at verified_at verification_retry_count verification_checksum],
- remove_with: '13.7',
- remove_after: '2020-12-22'
HEX_REGEXP = %r{\A\h+\z}.freeze
UUID_LENGTH = 32
@@ -35,20 +28,9 @@ module Terraform
format: { with: HEX_REGEXP, message: 'only allows hex characters' }
default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) }
- default_value_for(:versioning_enabled, true)
-
- mount_file_store_uploader StateUploader
-
- def file_store
- super || StateUploader.default_store
- end
def latest_file
- if versioning_enabled?
- latest_version&.file
- else
- latest_version&.file || file
- end
+ latest_version&.file
end
def locked?
@@ -56,13 +38,14 @@ module Terraform
end
def update_file!(data, version:, build:)
+ # This check is required to maintain backwards compatibility with
+ # states that were created prior to versioning being supported.
+ # This can be removed in 14.0 when support for these states is dropped.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960
if versioning_enabled?
create_new_version!(data: data, version: version, build: build)
- elsif latest_version.present?
- migrate_legacy_version!(data: data, version: version, build: build)
else
- self.file = data
- save!
+ migrate_legacy_version!(data: data, version: version, build: build)
end
end
diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb
index cc5d94b8e09..19d708616fc 100644
--- a/app/models/terraform/state_version.rb
+++ b/app/models/terraform/state_version.rb
@@ -10,9 +10,9 @@ module Terraform
scope :ordered_by_version_desc, -> { order(version: :desc) }
- default_value_for(:file_store) { VersionedStateUploader.default_store }
+ default_value_for(:file_store) { StateUploader.default_store }
- mount_file_store_uploader VersionedStateUploader
+ mount_file_store_uploader StateUploader
delegate :project_id, :uuid, to: :terraform_state, allow_nil: true
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index 60aaaaef831..f4debedb656 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -1,8 +1,10 @@
# frozen_string_literal: true
class Timelog < ApplicationRecord
+ include Importable
+
validates :time_spent, :user, presence: true
- validate :issuable_id_is_present
+ validate :issuable_id_is_present, unless: :importing?
belongs_to :issue, touch: true
belongs_to :merge_request, touch: true
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 0d893b25253..12dc9ce0fe6 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -139,13 +139,11 @@ class Todo < ApplicationRecord
# Todos with highest priority first then oldest todos
# Need to order by created_at last because of differences on Mysql and Postgres when joining by type "Merge_request/Issue"
def order_by_labels_priority
- params = {
+ highest_priority = highest_label_priority(
target_type_column: "todos.target_type",
target_column: "todos.target_id",
project_column: "todos.project_id"
- }
-
- highest_priority = highest_label_priority(params).to_sql
+ ).to_sql
select("#{table_name}.*, (#{highest_priority}) AS highest_priority")
.order(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
diff --git a/app/models/user.rb b/app/models/user.rb
index be64e057d59..c735f20b92c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -25,11 +25,14 @@ class User < ApplicationRecord
include IgnorableColumns
include UpdateHighestRole
include HasUserType
+ include Gitlab::Auth::Otp::Fortinet
DEFAULT_NOTIFICATION_LEVEL = :participating
INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
+ BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval'.freeze
+
add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) }
add_authentication_token_field :feed_token
add_authentication_token_field :static_object_token
@@ -166,6 +169,7 @@ class User < ApplicationRecord
has_many :issue_assignees, inverse_of: :assignee
has_many :merge_request_assignees, inverse_of: :assignee
+ has_many :merge_request_reviewers, inverse_of: :reviewer
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
has_many :assigned_merge_requests, class_name: "MergeRequest", through: :merge_request_assignees, source: :merge_request
@@ -286,6 +290,7 @@ class User < ApplicationRecord
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :job_title, :job_title=, to: :user_detail, allow_nil: true
+ delegate :other_role, :other_role=, to: :user_detail, allow_nil: true
delegate :bio, :bio=, :bio_html, to: :user_detail, allow_nil: true
delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true
@@ -587,11 +592,7 @@ class User < ApplicationRecord
sanitized_order_sql = Arel.sql(sanitize_sql_array([order, query: query]))
- where(
- fuzzy_arel_match(:name, query, lower_exact_match: true)
- .or(fuzzy_arel_match(:username, query, lower_exact_match: true))
- .or(arel_table[:email].eq(query))
- ).reorder(sanitized_order_sql, :name)
+ search_with_secondary_emails(query).reorder(sanitized_order_sql, :name)
end
# Limits the result set to users _not_ in the given query/list of IDs.
@@ -606,6 +607,18 @@ class User < ApplicationRecord
reorder(:name)
end
+ def search_without_secondary_emails(query)
+ return none if query.blank?
+
+ query = query.downcase
+
+ where(
+ fuzzy_arel_match(:name, query, lower_exact_match: true)
+ .or(fuzzy_arel_match(:username, query, lower_exact_match: true))
+ .or(arel_table[:email].eq(query))
+ )
+ end
+
# searches user by given pattern
# it compares name, email, username fields and user's secondary emails with given pattern
# This method uses ILIKE on PostgreSQL.
@@ -616,15 +629,16 @@ class User < ApplicationRecord
query = query.downcase
email_table = Email.arel_table
- matched_by_emails_user_ids = email_table
+ matched_by_email_user_id = email_table
.project(email_table[:user_id])
.where(email_table[:email].eq(query))
+ .take(1) # at most 1 record as there is a unique constraint
where(
fuzzy_arel_match(:name, query)
.or(fuzzy_arel_match(:username, query))
.or(arel_table[:email].eq(query))
- .or(arel_table[:id].in(matched_by_emails_user_ids))
+ .or(arel_table[:id].eq(matched_by_email_user_id))
)
end
@@ -708,6 +722,7 @@ class User < ApplicationRecord
u.name = 'GitLab Security Bot'
u.website_url = Gitlab::Routing.url_helpers.help_page_url('user/application_security/security_bot/index.md')
u.avatar = bot_avatar(image: 'security-bot.png')
+ u.confirmed_at = Time.zone.now
end
end
@@ -797,7 +812,9 @@ class User < ApplicationRecord
end
def two_factor_otp_enabled?
- otp_required_for_login? || Feature.enabled?(:forti_authenticator, self)
+ otp_required_for_login? ||
+ forti_authenticator_enabled?(self) ||
+ forti_token_cloud_enabled?(self)
end
def two_factor_u2f_enabled?
@@ -1032,7 +1049,7 @@ class User < ApplicationRecord
end
def require_personal_access_token_creation_for_git_auth?
- return false if allow_password_authentication_for_git? || ldap_user?
+ return false if allow_password_authentication_for_git? || password_based_omniauth_user?
PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none?
end
@@ -1050,7 +1067,7 @@ class User < ApplicationRecord
end
def allow_password_authentication_for_git?
- Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !ldap_user?
+ Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !password_based_omniauth_user?
end
def can_change_username?
@@ -1130,6 +1147,18 @@ class User < ApplicationRecord
namespace.find_fork_of(project)
end
+ def password_based_omniauth_user?
+ ldap_user? || crowd_user?
+ end
+
+ def crowd_user?
+ if identities.loaded?
+ identities.find { |identity| identity.provider == 'crowd' && identity.extern_uid.present? }
+ else
+ identities.with_any_extern_uid('crowd').exists?
+ end
+ end
+
def ldap_user?
if identities.loaded?
identities.find { |identity| Gitlab::Auth::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? }
@@ -1229,7 +1258,7 @@ class User < ApplicationRecord
end
def solo_owned_groups
- @solo_owned_groups ||= owned_groups.select do |group|
+ @solo_owned_groups ||= owned_groups.includes(:owners).select do |group|
group.owners == [self]
end
end
@@ -1464,6 +1493,10 @@ class User < ApplicationRecord
!solo_owned_groups.present?
end
+ def can_remove_self?
+ true
+ end
+
def ci_owned_runners
@ci_owned_runners ||= begin
project_runners = Ci::RunnerProject
@@ -1636,11 +1669,11 @@ class User < ApplicationRecord
save
end
- # each existing user needs to have an `feed_token`.
+ # each existing user needs to have a `feed_token`.
# we do this on read since migrating all existing users is not a feasible
# solution.
def feed_token
- ensure_feed_token!
+ Gitlab::CurrentSettings.disable_feed_token ? nil : ensure_feed_token!
end
# Each existing user needs to have a `static_object_token`.
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index cfad58fc0db..ad5651f9439 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -26,7 +26,8 @@ class UserCallout < ApplicationRecord
suggest_pipeline: 22,
customize_homepage: 23,
feature_flags_new_version: 24,
- registration_enabled_callout: 25
+ registration_enabled_callout: 25,
+ new_user_signups_cap_reached: 26 # EE-only
}
validates :user, presence: true
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index 9674f9a41da..ef799b01452 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -31,3 +31,5 @@ class UserDetail < ApplicationRecord
self.bio = '' if bio_changed? && bio.nil?
end
end
+
+UserDetail.prepend_if_ee('EE::UserDetail')
diff --git a/app/models/wiki_page/meta.rb b/app/models/wiki_page/meta.rb
index 215d84dc463..70b5547ffad 100644
--- a/app/models/wiki_page/meta.rb
+++ b/app/models/wiki_page/meta.rb
@@ -2,149 +2,20 @@
class WikiPage
class Meta < ApplicationRecord
- include Gitlab::Utils::StrongMemoize
-
- CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid)
- WikiPageInvalid = Class.new(ArgumentError)
+ include HasWikiPageMetaAttributes
self.table_name = 'wiki_page_meta'
belongs_to :project
has_many :slugs, class_name: 'WikiPage::Slug', foreign_key: 'wiki_page_meta_id', inverse_of: :wiki_page_meta
- has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- validates :title, presence: true
validates :project_id, presence: true
- validate :no_two_metarecords_in_same_project_can_have_same_canonical_slug
-
- scope :with_canonical_slug, ->(slug) do
- joins(:slugs).where(wiki_page_slugs: { canonical: true, slug: slug })
- end
alias_method :resource_parent, :project
- class << self
- # Return the (updated) WikiPage::Meta record for a given wiki page
- #
- # If none is found, then a new record is created, and its fields are set
- # to reflect the wiki_page passed.
- #
- # @param [String] last_known_slug
- # @param [WikiPage] wiki_page
- #
- # This method raises errors on validation issues.
- def find_or_create(last_known_slug, wiki_page)
- raise WikiPageInvalid unless wiki_page.valid?
-
- project = wiki_page.wiki.project
- known_slugs = [last_known_slug, wiki_page.slug].compact.uniq
- raise 'No slugs found! This should not be possible.' if known_slugs.empty?
-
- transaction do
- updates = wiki_page_updates(wiki_page)
- found = find_by_canonical_slug(known_slugs, project)
- meta = found || create!(updates.merge(project_id: project.id))
-
- meta.update_state(found.nil?, known_slugs, wiki_page, updates)
-
- # We don't need to run validations here, since find_by_canonical_slug
- # guarantees that there is no conflict in canonical_slug, and DB
- # constraints on title and project_id enforce our other invariants
- # This saves us a query.
- meta
- end
- end
-
- def find_by_canonical_slug(canonical_slug, project)
- meta, conflict = with_canonical_slug(canonical_slug)
- .where(project_id: project.id)
- .limit(2)
-
- if conflict.present?
- meta.errors.add(:canonical_slug, 'Duplicate value found')
- raise CanonicalSlugConflictError.new(meta)
- end
-
- meta
- end
-
- private
-
- def wiki_page_updates(wiki_page)
- last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc
-
- {
- title: wiki_page.title,
- created_at: last_commit_date,
- updated_at: last_commit_date
- }
- end
- end
-
- def canonical_slug
- strong_memoize(:canonical_slug) { slugs.canonical.first&.slug }
- end
-
- def canonical_slug=(slug)
- return if @canonical_slug == slug
-
- if persisted?
- transaction do
- slugs.canonical.update_all(canonical: false)
- page_slug = slugs.create_with(canonical: true).find_or_create_by(slug: slug)
- page_slug.update_columns(canonical: true) unless page_slug.canonical?
- end
- else
- slugs.new(slug: slug, canonical: true)
- end
-
- @canonical_slug = slug
- end
-
- def update_state(created, known_slugs, wiki_page, updates)
- update_wiki_page_attributes(updates)
- insert_slugs(known_slugs, created, wiki_page.slug)
- self.canonical_slug = wiki_page.slug
- end
-
- private
-
- def update_wiki_page_attributes(updates)
- # Remove all unnecessary updates:
- updates.delete(:updated_at) if updated_at == updates[:updated_at]
- updates.delete(:created_at) if created_at <= updates[:created_at]
- updates.delete(:title) if title == updates[:title]
-
- update_columns(updates) unless updates.empty?
- end
-
- def insert_slugs(strings, is_new, canonical_slug)
- creation = Time.current.utc
-
- slug_attrs = strings.map do |slug|
- {
- wiki_page_meta_id: id,
- slug: slug,
- canonical: (is_new && slug == canonical_slug),
- created_at: creation,
- updated_at: creation
- }
- end
- slugs.insert_all(slug_attrs) unless !is_new && slug_attrs.size == 1
-
- @canonical_slug = canonical_slug if is_new || strings.size == 1
- end
-
- def no_two_metarecords_in_same_project_can_have_same_canonical_slug
- return unless project_id.present? && canonical_slug.present?
-
- offending = self.class.with_canonical_slug(canonical_slug).where(project_id: project_id)
- offending = offending.where.not(id: id) if persisted?
-
- if offending.exists?
- errors.add(:canonical_slug, 'each page in a wiki must have a distinct canonical slug')
- end
+ def self.container_key
+ :project_id
end
end
end
diff --git a/app/models/wiki_page/slug.rb b/app/models/wiki_page/slug.rb
index c1725d34921..b82386c0e3c 100644
--- a/app/models/wiki_page/slug.rb
+++ b/app/models/wiki_page/slug.rb
@@ -2,25 +2,14 @@
class WikiPage
class Slug < ApplicationRecord
- self.table_name = 'wiki_page_slugs'
-
- belongs_to :wiki_page_meta, class_name: 'WikiPage::Meta', inverse_of: :slugs
-
- validates :slug, presence: true, uniqueness: { scope: :wiki_page_meta_id }
- validates :canonical, uniqueness: {
- scope: :wiki_page_meta_id,
- if: :canonical?,
- message: 'Only one slug can be canonical per wiki metadata record'
- }
+ def self.meta_foreign_key
+ :wiki_page_meta_id
+ end
- scope :canonical, -> { where(canonical: true) }
+ include HasWikiPageSlugAttributes
- def update_columns(attrs = {})
- super(attrs.reverse_merge(updated_at: Time.current.utc))
- end
+ self.table_name = 'wiki_page_slugs'
- def self.update_all(attrs = {})
- super(attrs.reverse_merge(updated_at: Time.current.utc))
- end
+ belongs_to :wiki_page_meta, class_name: 'WikiPage::Meta', inverse_of: :slugs
end
end
diff --git a/app/models/zoom_meeting.rb b/app/models/zoom_meeting.rb
index f83aa93b69a..c8b510c4779 100644
--- a/app/models/zoom_meeting.rb
+++ b/app/models/zoom_meeting.rb
@@ -1,13 +1,17 @@
# frozen_string_literal: true
class ZoomMeeting < ApplicationRecord
+ include Importable
include UsageStatistics
- belongs_to :project, optional: false
- belongs_to :issue, optional: false
+ belongs_to :project
+ belongs_to :issue
+
+ validates :project, presence: true, unless: :importing?
+ validates :issue, presence: true, unless: :importing?
validates :url, presence: true, length: { maximum: 255 }, zoom_url: true
- validates :issue, same_project_association: true
+ validates :issue, same_project_association: true, unless: :importing?
enum issue_status: {
added: 1,
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 580a348b408..51694ec7c50 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -25,6 +25,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:support_bot) { @user&.support_bot? }
+ desc "User is security bot"
+ with_options scope: :user, score: 0
+ condition(:security_bot) { @user&.security_bot? }
+
desc "User email is unconfirmed or user account is locked"
with_options scope: :user, score: 0
condition(:inactive) { @user&.confirmation_required_on_sign_in? || @user&.access_locked? }
diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb
index 3efc07421e4..7e69e1fdd88 100644
--- a/app/policies/ci/build_policy.rb
+++ b/app/policies/ci/build_policy.rb
@@ -45,6 +45,21 @@ module Ci
@subject.pipeline.webide?
end
+ condition(:debug_mode, scope: :subject, score: 32) do
+ @subject.debug_mode?
+ end
+
+ condition(:project_read_build, scope: :subject) do
+ can?(:read_build, @subject.project)
+ end
+
+ condition(:project_update_build, scope: :subject) do
+ can?(:update_build, @subject.project)
+ end
+
+ rule { project_read_build }.enable :read_build_trace
+ rule { debug_mode & ~project_update_build }.prevent :read_build_trace
+
rule { ~protected_environment_access & (protected_ref | archived) }.policy do
prevent :update_build
prevent :update_commit_status
diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb
index 7eca6f4c6c8..75849fb10c8 100644
--- a/app/policies/concerns/policy_actor.rb
+++ b/app/policies/concerns/policy_actor.rb
@@ -49,6 +49,10 @@ module PolicyActor
false
end
+ def security_bot?
+ false
+ end
+
def deactivated?
false
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index c1ea4dddb51..b5c1ec0181e 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -48,7 +48,7 @@ class GlobalPolicy < BasePolicy
prevent :use_slash_commands
end
- rule { blocked | (internal & ~migration_bot) }.policy do
+ rule { blocked | (internal & ~migration_bot & ~security_bot) }.policy do
prevent :access_git
end
@@ -99,6 +99,7 @@ class GlobalPolicy < BasePolicy
enable :read_custom_attribute
enable :update_custom_attribute
enable :approve_user
+ enable :reject_user
end
# We can't use `read_statistics` because the user may have different permissions for different projects
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 231843c5f23..7d0db222eaf 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -185,7 +185,10 @@ class GroupPolicy < BasePolicy
rule { developer & developer_maintainer_access }.enable :create_projects
rule { create_projects_disabled }.prevent :create_projects
- rule { owner | admin }.enable :read_statistics
+ rule { owner | admin }.policy do
+ enable :owner_access
+ enable :read_statistics
+ end
rule { maintainer & can?(:create_projects) }.enable :transfer_projects
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index 5cfbcfec5c0..f49a6ee8498 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -27,3 +27,5 @@ class IssuablePolicy < BasePolicy
prevent :award_emoji
end
end
+
+IssuablePolicy.prepend_if_ee('EE::IssuablePolicy')
diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb
index aa87442cadd..b1d680b4264 100644
--- a/app/policies/namespace_policy.rb
+++ b/app/policies/namespace_policy.rb
@@ -8,6 +8,7 @@ class NamespacePolicy < BasePolicy
condition(:owner) { @subject.owner == @user }
rule { owner | admin }.policy do
+ enable :owner_access
enable :create_projects
enable :admin_namespace
enable :read_namespace
diff --git a/app/policies/project_ci_cd_setting_policy.rb b/app/policies/project_ci_cd_setting_policy.rb
new file mode 100644
index 00000000000..a22b790415b
--- /dev/null
+++ b/app/policies/project_ci_cd_setting_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ProjectCiCdSettingPolicy < BasePolicy
+ delegate { @subject.project }
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 13073ed68a1..403fb34803e 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -135,6 +135,10 @@ class ProjectPolicy < BasePolicy
::Feature.enabled?(:build_service_proxy, @subject)
end
+ condition(:project_bot_is_member) do
+ user.project_bot? & team_member?
+ end
+
with_scope :subject
condition(:packages_disabled) { !@subject.packages_enabled }
@@ -147,6 +151,8 @@ class ProjectPolicy < BasePolicy
builds
pages
metrics_dashboard
+ analytics
+ operations
]
features.each do |f|
@@ -211,6 +217,7 @@ class ProjectPolicy < BasePolicy
enable :award_emoji
enable :read_pages_content
enable :read_release
+ enable :read_analytics
end
# These abilities are not allowed to admins that are not members of the project,
@@ -272,6 +279,19 @@ class ProjectPolicy < BasePolicy
prevent(:metrics_dashboard)
end
+ rule { operations_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:feature_flag))
+ prevent(*create_read_update_admin_destroy(:environment))
+ prevent(*create_read_update_admin_destroy(:sentry_issue))
+ prevent(*create_read_update_admin_destroy(:alert_management_alert))
+ prevent(*create_read_update_admin_destroy(:cluster))
+ prevent(*create_read_update_admin_destroy(:terraform_state))
+ prevent(*create_read_update_admin_destroy(:deployment))
+ prevent(:metrics_dashboard)
+ prevent(:read_pod_logs)
+ prevent(:read_prometheus)
+ end
+
rule { can?(:metrics_dashboard) }.policy do
enable :read_prometheus
enable :read_deployment
@@ -424,6 +444,10 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:snippet))
end
+ rule { analytics_disabled }.policy do
+ prevent(:read_analytics)
+ end
+
rule { wiki_disabled }.policy do
prevent(*create_read_update_admin_destroy(:wiki))
prevent(:download_wiki_code)
@@ -494,6 +518,7 @@ class ProjectPolicy < BasePolicy
enable :download_wiki_code
enable :read_cycle_analytics
enable :read_pages_content
+ enable :read_analytics
# NOTE: may be overridden by IssuePolicy
enable :read_issue
@@ -594,6 +619,8 @@ class ProjectPolicy < BasePolicy
enable :admin_resource_access_tokens
end
+ rule { project_bot_is_member & ~blocked }.enable :bot_log_in
+
private
def user_is_user?
diff --git a/app/policies/timebox_policy.rb b/app/policies/timebox_policy.rb
new file mode 100644
index 00000000000..03a1acb9358
--- /dev/null
+++ b/app/policies/timebox_policy.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class TimeboxPolicy < BasePolicy
+ # stub permissions policy on None, Any, Upcoming, Started and Current timeboxes
+
+ rule { default }.policy do
+ enable :read_iteration
+ enable :read_milestone
+ end
+end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 70e8fb32064..48c2bd3f0bd 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -13,6 +13,9 @@ class UserPolicy < BasePolicy
desc "The user is blocked"
condition(:blocked_user, scope: :subject, score: 0) { @subject.blocked? }
+ desc "The user is unconfirmed"
+ condition(:unconfirmed_user, scope: :subject, score: 0) { !@subject.confirmed? }
+
rule { ~restricted_public_level }.enable :read_user
rule { ~anonymous }.enable :read_user
@@ -25,7 +28,7 @@ class UserPolicy < BasePolicy
end
rule { default }.enable :read_user_profile
- rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile
+ rule { (private_profile | blocked_user | unconfirmed_user) & ~(user_is_self | admin) }.prevent :read_user_profile
rule { user_is_self | admin }.enable :disable_two_factor
rule { (user_is_self | admin) & ~blocked }.enable :create_user_personal_access_token
end
diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb
index 4bfa3dc9a13..1cebf5c561a 100644
--- a/app/presenters/alert_management/alert_presenter.rb
+++ b/app/presenters/alert_management/alert_presenter.rb
@@ -8,7 +8,6 @@ module AlertManagement
MARKDOWN_LINE_BREAK = " \n"
HORIZONTAL_LINE = "\n\n---\n\n"
- INCIDENT_LABEL_NAME = ::IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES[:title]
delegate :metrics_dashboard_url, :runbook, to: :parsed_payload
@@ -48,7 +47,7 @@ module AlertManagement
end
def incident_issues_link
- project_issues_url(project, label_name: INCIDENT_LABEL_NAME)
+ project_incidents_url(project)
end
def performance_dashboard_link
diff --git a/app/presenters/analytics/cycle_analytics/stage_presenter.rb b/app/presenters/analytics/cycle_analytics/stage_presenter.rb
new file mode 100644
index 00000000000..7b295b814bc
--- /dev/null
+++ b/app/presenters/analytics/cycle_analytics/stage_presenter.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Analytics
+ module CycleAnalytics
+ class StagePresenter < Gitlab::View::Presenter::Delegated
+ def title
+ extract_default_stage_attribute(:title) || name
+ end
+
+ def description
+ extract_default_stage_attribute(:description) || ''
+ end
+
+ def legend
+ ''
+ end
+
+ private
+
+ def extract_default_stage_attribute(attribute)
+ default_stage_attributes.dig(name.to_sym, attribute.to_sym)
+ end
+
+ def default_stage_attributes
+ @default_stage_attributes ||= {
+ issue: {
+ title: s_('CycleAnalyticsStage|Issue'),
+ description: _('Time before an issue gets scheduled')
+ },
+ plan: {
+ title: s_('CycleAnalyticsStage|Plan'),
+ description: _('Time before an issue starts implementation')
+ },
+ code: {
+ title: s_('CycleAnalyticsStage|Code'),
+ description: _('Time until first merge request')
+ },
+ test: {
+ title: s_('CycleAnalyticsStage|Test'),
+ description: _('Total test time for all commits/merges')
+ },
+ review: {
+ title: s_('CycleAnalyticsStage|Review'),
+ description: _('Time between merge request creation and merge/close')
+ },
+ staging: {
+ title: s_('CycleAnalyticsStage|Staging'),
+ description: _('From merge request merge until deploy to production')
+ },
+ production: {
+ title: s_('CycleAnalyticsStage|Total'),
+ description: _('From issue creation until deploy to production')
+ }
+ }.freeze
+ end
+ end
+ end
+end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index da610f13899..f3bb63b31c3 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -10,7 +10,8 @@ module Ci
def self.failure_reasons
{ unknown_failure: 'Unknown pipeline failure!',
config_error: 'CI/CD YAML configuration error!',
- external_validation_failure: 'External pipeline validation failed!' }
+ external_validation_failure: 'External pipeline validation failed!',
+ deployments_limit_exceeded: 'Pipeline deployments limit exceeded!' }
end
presents :pipeline
diff --git a/app/presenters/gitlab/whats_new/item_presenter.rb b/app/presenters/gitlab/whats_new/item_presenter.rb
new file mode 100644
index 00000000000..9f66e19ade0
--- /dev/null
+++ b/app/presenters/gitlab/whats_new/item_presenter.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module WhatsNew
+ class ItemPresenter
+ DICTIONARY = {
+ core: 'Free',
+ starter: 'Bronze',
+ premium: 'Silver',
+ ultimate: 'Gold'
+ }.freeze
+
+ def self.present(item)
+ if Gitlab.com?
+ item['packages'] = item['packages'].map { |p| DICTIONARY[p.downcase.to_sym] }
+ end
+
+ item
+ end
+ end
+ end
+end
diff --git a/app/presenters/packages/composer/packages_presenter.rb b/app/presenters/packages/composer/packages_presenter.rb
index 84f266989e9..cce006cbb1a 100644
--- a/app/presenters/packages/composer/packages_presenter.rb
+++ b/app/presenters/packages/composer/packages_presenter.rb
@@ -11,7 +11,7 @@ module Packages
end
def root
- path = api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%', format: '.json' }, true)
+ path = api_v4_group___packages_composer_package_name_path({ id: @group.id, package_name: '%package%$%hash%', format: '.json' }, true)
{ 'packages' => [], 'provider-includes' => { 'p/%hash%.json' => { 'sha256' => provider_sha } }, 'providers-url' => path }
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 0f5b601f2b0..55b550d8544 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -77,19 +77,19 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def readme_path
- filename_path(:readme)
+ filename_path(repository.readme_path)
end
def changelog_path
- filename_path(:changelog)
+ filename_path(repository.changelog&.name)
end
def license_path
- filename_path(:license_blob)
+ filename_path(repository.license_blob&.name)
end
def ci_configuration_path
- filename_path(:gitlab_ci_yml)
+ filename_path(repository.gitlab_ci_yml&.name)
end
def contribution_guide_path
@@ -244,11 +244,11 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def readme_anchor_data
- if current_user && can_current_user_push_to_default_branch? && repository.readme.nil?
+ if current_user && can_current_user_push_to_default_branch? && readme_path.nil?
AnchorData.new(false,
statistic_icon + _('Add README'),
empty_repo? ? add_readme_ide_path : add_readme_path)
- elsif repository.readme
+ elsif readme_path
AnchorData.new(false,
statistic_icon('doc-text') + _('README'),
default_view != 'readme' ? readme_path : '#readme',
@@ -397,13 +397,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
current_user && can?(current_user, :create_cluster, project)
end
- def filename_path(filename)
- if blob = repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend
- project_blob_path(
- project,
- tree_join(default_branch, blob.name)
- )
- end
+ def filename_path(filepath)
+ return if filepath.blank?
+
+ project_blob_path(project, tree_join(default_branch, filepath))
end
def anonymous_project_view
diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb
index 8f3fc53af10..b52f3411c49 100644
--- a/app/presenters/projects/import_export/project_export_presenter.rb
+++ b/app/presenters/projects/import_export/project_export_presenter.rb
@@ -15,6 +15,10 @@ module Projects
self.respond_to?(:override_description) ? override_description : super
end
+ def protected_branches
+ project.exported_protected_branches
+ end
+
private
def converted_group_members
diff --git a/app/presenters/search_service_presenter.rb b/app/presenters/search_service_presenter.rb
new file mode 100644
index 00000000000..19a90d002aa
--- /dev/null
+++ b/app/presenters/search_service_presenter.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+class SearchServicePresenter < Gitlab::View::Presenter::Delegated
+ include RendersCommits
+
+ presents :search_service
+
+ SCOPE_PRELOAD_METHOD = {
+ projects: :with_web_entity_associations,
+ issues: :with_web_entity_associations,
+ merge_requests: :with_web_entity_associations,
+ epics: :with_web_entity_associations
+ }.freeze
+
+ SORT_ENABLED_SCOPES = %w(issues merge_requests).freeze
+
+ def search_objects
+ @search_objects ||= begin
+ objects = search_service.search_objects(SCOPE_PRELOAD_METHOD[scope.to_sym])
+
+ case scope
+ when 'users'
+ objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
+ when 'commits'
+ prepare_commits_for_rendering(objects)
+ else
+ objects
+ end
+ end
+ end
+
+ def show_sort_dropdown?
+ SORT_ENABLED_SCOPES.include?(scope)
+ end
+
+ def show_results_status?
+ !without_count? || show_snippets? || show_sort_dropdown?
+ end
+
+ def without_count?
+ search_objects.is_a?(Kaminari::PaginatableWithoutCount)
+ end
+end
diff --git a/app/serializers/admin/user_entity.rb b/app/serializers/admin/user_entity.rb
new file mode 100644
index 00000000000..ad96c101822
--- /dev/null
+++ b/app/serializers/admin/user_entity.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Admin
+ class UserEntity < API::Entities::UserSafe
+ include RequestAwareEntity
+ include UsersHelper
+ include UserActionsHelper
+
+ expose :created_at
+ expose :email
+ expose :last_activity_on
+ expose :avatar_url
+ expose :badges do |user|
+ user_badges_in_admin_section(user)
+ end
+
+ expose :projects_count do |user|
+ user.authorized_projects.length
+ end
+
+ expose :actions do |user|
+ admin_actions(user)
+ end
+
+ private
+
+ def current_user
+ options[:current_user]
+ end
+ end
+end
diff --git a/app/serializers/admin/user_serializer.rb b/app/serializers/admin/user_serializer.rb
new file mode 100644
index 00000000000..09036428bab
--- /dev/null
+++ b/app/serializers/admin/user_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Admin
+ class UserSerializer < BaseSerializer
+ entity UserEntity
+ end
+end
diff --git a/app/serializers/codequality_degradation_entity.rb b/app/serializers/codequality_degradation_entity.rb
new file mode 100644
index 00000000000..be561052507
--- /dev/null
+++ b/app/serializers/codequality_degradation_entity.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class CodequalityDegradationEntity < Grape::Entity
+ expose :description
+ expose :severity
+
+ expose :file_path do |degradation|
+ degradation.dig(:location, :path)
+ end
+
+ expose :line do |degradation|
+ degradation.dig(:location, :lines, :begin) || degradation.dig(:location, :positions, :begin, :line)
+ end
+end
diff --git a/app/serializers/codequality_reports_comparer_entity.rb b/app/serializers/codequality_reports_comparer_entity.rb
new file mode 100644
index 00000000000..1de4e56c57d
--- /dev/null
+++ b/app/serializers/codequality_reports_comparer_entity.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CodequalityReportsComparerEntity < Grape::Entity
+ expose :status
+
+ expose :new_errors, using: CodequalityDegradationEntity
+ expose :resolved_errors, using: CodequalityDegradationEntity
+ expose :existing_errors, using: CodequalityDegradationEntity
+
+ expose :summary do
+ expose :total_count, as: :total
+ expose :resolved_count, as: :resolved
+ expose :errors_count, as: :errored
+ end
+end
diff --git a/app/serializers/codequality_reports_comparer_serializer.rb b/app/serializers/codequality_reports_comparer_serializer.rb
new file mode 100644
index 00000000000..2c6eb33aa9f
--- /dev/null
+++ b/app/serializers/codequality_reports_comparer_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class CodequalityReportsComparerSerializer < BaseSerializer
+ entity CodequalityReportsComparerEntity
+end
diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb
index 633b117d392..fcf6700cb59 100644
--- a/app/serializers/concerns/user_status_tooltip.rb
+++ b/app/serializers/concerns/user_status_tooltip.rb
@@ -8,12 +8,18 @@ module UserStatusTooltip
include UsersHelper
included do
- expose :user_status_if_loaded, as: :status_tooltip_html
+ expose :status_tooltip_html, if: -> (*) { status_loaded? } do |user|
+ user_status(user)
+ end
+
+ expose :show_status do |user|
+ status_loaded? && show_status_emoji?(user.status)
+ end
- def user_status_if_loaded
- return unless object.association(:status).loaded?
+ private
- user_status(object)
+ def status_loaded?
+ object.association(:status).loaded?
end
end
end
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index 5036f28184c..1409f023f21 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -118,7 +118,7 @@ class DiffFileBaseEntity < Grape::Entity
strong_memoize(:submodule_links) do
next unless diff_file.submodule?
- options[:submodule_links].for(diff_file.blob, diff_file.content_sha, diff_file)
+ options[:submodule_links]&.for(diff_file.blob, diff_file.content_sha, diff_file)
end
end
diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb
index 8973f23734a..7b0de3bce4e 100644
--- a/app/serializers/diffs_metadata_entity.rb
+++ b/app/serializers/diffs_metadata_entity.rb
@@ -2,7 +2,9 @@
class DiffsMetadataEntity < DiffsEntity
unexpose :diff_files
- expose :raw_diff_files, as: :diff_files, using: DiffFileMetadataEntity
+ expose :diff_files, using: DiffFileMetadataEntity do |diffs, _|
+ diffs.raw_diff_files(sorted: true)
+ end
expose :conflict_resolution_path do |_, options|
presenter(options[:merge_request]).conflict_resolution_path
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 0bd9c602bf5..8c6ad010d69 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -3,6 +3,9 @@
class EnvironmentEntity < Grape::Entity
include RequestAwareEntity
+ UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT =
+ %i[manual_actions scheduled_actions playable_build cluster].freeze
+
expose :id
expose :global_id do |environment|
@@ -17,6 +20,11 @@ class EnvironmentEntity < Grape::Entity
expose :last_deployment, using: DeploymentEntity
expose :stop_action_available?, as: :has_stop_action
+ expose :upcoming_deployment, expose_nil: false do |environment, ops|
+ DeploymentEntity.represent(environment.upcoming_deployment,
+ ops.merge(except: UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT))
+ end
+
expose :metrics_path, if: -> (*) { environment.has_metrics? } do |environment|
metrics_project_environment_path(environment.project, environment)
end
diff --git a/app/serializers/import/bulk_import_entity.rb b/app/serializers/import/bulk_import_entity.rb
index 8f0a9dd4428..9daa6699a20 100644
--- a/app/serializers/import/bulk_import_entity.rb
+++ b/app/serializers/import/bulk_import_entity.rb
@@ -12,4 +12,8 @@ class Import::BulkImportEntity < Grape::Entity
expose :full_path do |entity|
entity['full_path']
end
+
+ expose :web_url do |entity|
+ entity['web_url']
+ end
end
diff --git a/app/serializers/merge_request_assignee_entity.rb b/app/serializers/merge_request_assignee_entity.rb
deleted file mode 100644
index b7ef7449270..00000000000
--- a/app/serializers/merge_request_assignee_entity.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-class MergeRequestAssigneeEntity < ::API::Entities::UserBasic
- expose :can_merge do |assignee, options|
- options[:merge_request]&.can_be_merged_by?(assignee)
- end
-end
-
-MergeRequestAssigneeEntity.prepend_if_ee('EE::MergeRequestAssigneeEntity')
diff --git a/app/serializers/merge_request_current_user_entity.rb b/app/serializers/merge_request_current_user_entity.rb
new file mode 100644
index 00000000000..fbdb4e505ec
--- /dev/null
+++ b/app/serializers/merge_request_current_user_entity.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class MergeRequestCurrentUserEntity < CurrentUserEntity
+ include RequestAwareEntity
+ include BlobHelper
+ include TreeHelper
+
+ expose :can_fork do |user|
+ project && can?(user, :fork_project, request.project)
+ end
+
+ expose :can_create_merge_request do |user|
+ project && can?(user, :create_merge_request_in, project)
+ end
+
+ expose :fork_path, if: -> (*) { project } do |user|
+ params = edit_blob_fork_params("Edit")
+ project_forks_path(project, namespace_key: user.namespace.id, continue: params)
+ end
+
+ def project
+ request.respond_to?(:project) && request.project
+ end
+end
diff --git a/app/serializers/merge_request_reviewer_entity.rb b/app/serializers/merge_request_reviewer_entity.rb
deleted file mode 100644
index fefd116014f..00000000000
--- a/app/serializers/merge_request_reviewer_entity.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-class MergeRequestReviewerEntity < ::API::Entities::UserBasic
- expose :can_merge do |reviewer, options|
- options[:merge_request]&.can_be_merged_by?(reviewer)
- end
-end
-
-MergeRequestReviewerEntity.prepend_if_ee('EE::MergeRequestReviewerEntity')
diff --git a/app/serializers/merge_request_sidebar_extras_entity.rb b/app/serializers/merge_request_sidebar_extras_entity.rb
index 9db8e52abef..261b6e8e519 100644
--- a/app/serializers/merge_request_sidebar_extras_entity.rb
+++ b/app/serializers/merge_request_sidebar_extras_entity.rb
@@ -2,10 +2,10 @@
class MergeRequestSidebarExtrasEntity < IssuableSidebarExtrasEntity
expose :assignees do |merge_request|
- MergeRequestAssigneeEntity.represent(merge_request.assignees, merge_request: merge_request)
+ MergeRequestUserEntity.represent(merge_request.assignees, merge_request: merge_request)
end
expose :reviewers, if: -> (m) { m.allows_reviewers? } do |merge_request|
- MergeRequestReviewerEntity.represent(merge_request.reviewers, merge_request: merge_request)
+ MergeRequestUserEntity.represent(merge_request.reviewers, merge_request: merge_request)
end
end
diff --git a/app/serializers/merge_request_user_entity.rb b/app/serializers/merge_request_user_entity.rb
index 53257b0602c..604c9cabd50 100644
--- a/app/serializers/merge_request_user_entity.rb
+++ b/app/serializers/merge_request_user_entity.rb
@@ -1,26 +1,9 @@
# frozen_string_literal: true
-class MergeRequestUserEntity < CurrentUserEntity
- include RequestAwareEntity
- include BlobHelper
- include TreeHelper
-
- expose :can_fork do |user|
- can?(user, :fork_project, request.project) if project
- end
-
- expose :can_create_merge_request do |user|
- project && can?(user, :create_merge_request_in, project)
- end
-
- expose :fork_path, if: -> (*) { project } do |user|
- params = edit_blob_fork_params("Edit")
- project_forks_path(project, namespace_key: user.namespace.id, continue: params)
- end
-
- def project
- return false unless request.respond_to?(:project) && request.project
-
- request.project
+class MergeRequestUserEntity < ::API::Entities::UserBasic
+ expose :can_merge do |reviewer, options|
+ options[:merge_request]&.can_be_merged_by?(reviewer)
end
end
+
+MergeRequestUserEntity.prepend_if_ee('EE::MergeRequestUserEntity')
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index e46b269ea35..afd4d5b9a2b 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -2,6 +2,9 @@
class MergeRequestWidgetEntity < Grape::Entity
include RequestAwareEntity
+ include ProjectsHelper
+ include ApplicationHelper
+ include ApplicationSettingsHelper
SUGGEST_PIPELINE = 'suggest_pipeline'
@@ -48,6 +51,10 @@ class MergeRequestWidgetEntity < Grape::Entity
help_page_path('user/project/merge_requests/resolve_conflicts.md')
end
+ expose :reviewing_and_managing_merge_requests_docs_path do |merge_request|
+ help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally-through-the-head-ref")
+ end
+
expose :merge_request_pipelines_docs_path do |merge_request|
help_page_path('ci/merge_request_pipelines/index.md')
end
@@ -67,15 +74,15 @@ class MergeRequestWidgetEntity < Grape::Entity
)
end
- expose :user_callouts_path, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request|
+ expose :user_callouts_path do |_merge_request|
user_callouts_path
end
- expose :suggest_pipeline_feature_id, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request|
+ expose :suggest_pipeline_feature_id do |_merge_request|
SUGGEST_PIPELINE
end
- expose :is_dismissed_suggest_pipeline, if: -> (*) { Feature.enabled?(:suggest_pipeline, default_enabled: true) } do |_merge_request|
+ expose :is_dismissed_suggest_pipeline do |_merge_request|
current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE)
end
@@ -87,6 +94,10 @@ class MergeRequestWidgetEntity < Grape::Entity
new_project_pipeline_path(merge_request.project)
end
+ expose :source_project_default_url do |merge_request|
+ merge_request.source_project && default_url_to_repo(merge_request.source_project)
+ end
+
# Rendering and redacting Markdown can be expensive. These links are
# just nice to have in the merge request widget, so only
# include them if they are explicitly requested on first load.
diff --git a/app/serializers/paginated_diff_entity.rb b/app/serializers/paginated_diff_entity.rb
index fe59686278c..1118b1aa4fe 100644
--- a/app/serializers/paginated_diff_entity.rb
+++ b/app/serializers/paginated_diff_entity.rb
@@ -13,7 +13,7 @@ class PaginatedDiffEntity < Grape::Entity
submodule_links = Gitlab::SubmoduleLinks.new(merge_request.project.repository)
DiffFileEntity.represent(
- diffs.diff_files,
+ diffs.diff_files(sorted: true),
options.merge(
submodule_links: submodule_links,
code_navigation_path: code_navigation_path(diffs),
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index a45214670fa..ab2c6dfeace 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -75,3 +75,5 @@ class PipelineSerializer < BaseSerializer
]
end
end
+
+PipelineSerializer.prepend_if_ee('EE::PipelineSerializer')
diff --git a/app/serializers/rollout_status_entity.rb b/app/serializers/rollout_status_entity.rb
new file mode 100644
index 00000000000..9f4c844859b
--- /dev/null
+++ b/app/serializers/rollout_status_entity.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class RolloutStatusEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :status, as: :status
+
+ # To be removed in API v5
+ expose :has_legacy_app_label do |_rollout_status|
+ false
+ end
+
+ expose :instances, if: -> (rollout_status, _) { rollout_status.found? }
+ expose :completion, if: -> (rollout_status, _) { rollout_status.found? }
+ expose :complete?, as: :is_completed, if: -> (rollout_status, _) { rollout_status.found? }
+ expose :canary_ingress, using: RolloutStatuses::IngressEntity, expose_nil: false,
+ if: -> (rollout_status, _) { rollout_status.found? && rollout_status.canary_ingress_exists? }
+end
diff --git a/app/serializers/rollout_statuses/ingress_entity.rb b/app/serializers/rollout_statuses/ingress_entity.rb
new file mode 100644
index 00000000000..a68d936b86c
--- /dev/null
+++ b/app/serializers/rollout_statuses/ingress_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module RolloutStatuses
+ class IngressEntity < Grape::Entity
+ expose :canary_weight
+ end
+end
diff --git a/app/serializers/user_entity.rb b/app/serializers/user_entity.rb
index 8909ae8df2c..9386c06b87a 100644
--- a/app/serializers/user_entity.rb
+++ b/app/serializers/user_entity.rb
@@ -2,3 +2,5 @@
class UserEntity < API::Entities::UserPath
end
+
+UserEntity.prepend_if_ee('EE::UserEntity')
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index d988caea92d..dfbd787298d 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -8,7 +8,7 @@ class UserSerializer < BaseSerializer
merge_request = opts[:project].merge_requests.find_by_iid!(params[:merge_request_iid])
preload_max_member_access(merge_request.project, Array(resource))
- super(resource, opts.merge(merge_request: merge_request), MergeRequestAssigneeEntity)
+ super(resource, opts.merge(merge_request: merge_request), MergeRequestUserEntity)
else
super
end
@@ -20,3 +20,5 @@ class UserSerializer < BaseSerializer
project.team.max_member_access_for_user_ids(users.map(&:id))
end
end
+
+UserSerializer.prepend_if_ee('EE::UserSerializer')
diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb
index ddd5add42bd..253c3a84fef 100644
--- a/app/services/admin/propagate_integration_service.rb
+++ b/app/services/admin/propagate_integration_service.rb
@@ -7,7 +7,7 @@ module Admin
def propagate
if integration.instance?
update_inherited_integrations
- create_integration_for_groups_without_integration if Feature.enabled?(:group_level_integrations, default_enabled: true)
+ create_integration_for_groups_without_integration
create_integration_for_projects_without_integration
else
update_inherited_descendant_integrations
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
index 28ce5401a6c..753162bfdbf 100644
--- a/app/services/alert_management/process_prometheus_alert_service.rb
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -1,10 +1,16 @@
# frozen_string_literal: true
module AlertManagement
- class ProcessPrometheusAlertService < BaseService
+ class ProcessPrometheusAlertService
+ include BaseServiceUtility
include Gitlab::Utils::StrongMemoize
include ::IncidentManagement::Settings
+ def initialize(project, payload)
+ @project = project
+ @payload = payload
+ end
+
def execute
return bad_request unless incoming_payload.has_required_attributes?
@@ -19,6 +25,8 @@ module AlertManagement
private
+ attr_reader :project, :payload
+
def process_alert_management_alert
if incoming_payload.resolved?
process_resolved_alert_management_alert
@@ -127,7 +135,7 @@ module AlertManagement
strong_memoize(:incoming_payload) do
Gitlab::AlertManagement::Payload.parse(
project,
- params,
+ payload,
monitoring_tool: Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
)
end
diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb
index df9217bea32..7792b811b4e 100644
--- a/app/services/application_settings/update_service.rb
+++ b/app/services/application_settings/update_service.rb
@@ -9,6 +9,16 @@ module ApplicationSettings
MARKDOWN_CACHE_INVALIDATING_PARAMS = %w(asset_proxy_enabled asset_proxy_url asset_proxy_secret_key asset_proxy_whitelist).freeze
def execute
+ result = update_settings
+
+ auto_approve_blocked_users if result
+
+ result
+ end
+
+ private
+
+ def update_settings
validate_classification_label(application_setting, :external_authorization_service_default_label) unless bypass_external_auth?
if application_setting.errors.any?
@@ -40,8 +50,6 @@ module ApplicationSettings
@application_setting.save
end
- private
-
def usage_stats_updated?
params.key?(:usage_ping_enabled) || params.key?(:version_check_enabled)
end
@@ -95,6 +103,20 @@ module ApplicationSettings
def bypass_external_auth?
params.key?(:external_authorization_service_enabled) && !Gitlab::Utils.to_boolean(params[:external_authorization_service_enabled])
end
+
+ def auto_approve_blocked_users
+ return unless should_auto_approve_blocked_users?
+
+ ApproveBlockedPendingApprovalUsersWorker.perform_async(current_user.id)
+ end
+
+ def should_auto_approve_blocked_users?
+ return false unless application_setting.previous_changes.key?(:require_admin_approval_after_user_signup)
+
+ enabled_previous, enabled_current = application_setting.previous_changes[:require_admin_approval_after_user_signup]
+
+ enabled_previous && !enabled_current
+ end
end
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index 831a25a637e..d74f20511bd 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -130,6 +130,7 @@ module Auth
ContainerRepository.create_from_path!(path)
end
+ # Overridden in EE
def can_access?(requested_project, requested_action)
return false unless requested_project.container_registry_enabled?
return false if requested_project.repository_access_level == ::ProjectFeature::DISABLED
@@ -226,11 +227,16 @@ module Auth
end
end
+ # Overridden in EE
+ def extra_info
+ {}
+ end
+
def log_if_actions_denied(type, requested_project, requested_actions, authorized_actions)
return if requested_actions == authorized_actions
log_info = {
- message: "Denied container registry permissions",
+ message: 'Denied container registry permissions',
scope_type: type,
requested_project_path: requested_project.full_path,
requested_actions: requested_actions,
@@ -238,9 +244,11 @@ module Auth
username: current_user&.username,
user_id: current_user&.id,
project_path: project&.full_path
- }.compact
+ }.merge!(extra_info).compact
Gitlab::AuthLogger.warn(log_info)
end
end
end
+
+Auth::ContainerRegistryAuthenticationService.prepend_if_ee('EE::Auth::ContainerRegistryAuthenticationService')
diff --git a/app/services/auth/dependency_proxy_authentication_service.rb b/app/services/auth/dependency_proxy_authentication_service.rb
new file mode 100644
index 00000000000..1b8c16b7c79
--- /dev/null
+++ b/app/services/auth/dependency_proxy_authentication_service.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Auth
+ class DependencyProxyAuthenticationService < BaseService
+ AUDIENCE = 'dependency_proxy'
+ HMAC_KEY = 'gitlab-dependency-proxy'
+ DEFAULT_EXPIRE_TIME = 1.minute
+
+ def execute(authentication_abilities:)
+ return error('dependency proxy not enabled', 404) unless ::Gitlab.config.dependency_proxy.enabled
+ return error('access forbidden', 403) unless current_user
+
+ { token: authorized_token.encoded }
+ end
+
+ class << self
+ include ::Gitlab::Utils::StrongMemoize
+
+ def secret
+ strong_memoize(:secret) do
+ OpenSSL::HMAC.hexdigest(
+ 'sha256',
+ ::Settings.attr_encrypted_db_key_base,
+ HMAC_KEY
+ )
+ end
+ end
+
+ def token_expire_at
+ Time.current + Gitlab::CurrentSettings.container_registry_token_expire_delay.minutes
+ end
+ end
+
+ private
+
+ def authorized_token
+ JSONWebToken::HMACToken.new(self.class.secret).tap do |token|
+ token['user_id'] = current_user.id
+ token.expire_time = self.class.token_expire_at
+ end
+ end
+ end
+end
diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb
index 9c7a165776e..a21ceee083f 100644
--- a/app/services/boards/lists/create_service.rb
+++ b/app/services/boards/lists/create_service.rb
@@ -6,17 +6,21 @@ module Boards
include Gitlab::Utils::StrongMemoize
def execute(board)
- List.transaction do
- case type
- when :backlog
- create_backlog(board)
- else
- target = target(board)
- position = next_position(board)
-
- create_list(board, type, target, position)
- end
- end
+ list = case type
+ when :backlog
+ create_backlog(board)
+ else
+ target = target(board)
+ position = next_position(board)
+
+ return ServiceResponse.error(message: _('%{board_target} not found') % { board_target: type.to_s.capitalize }) if target.blank?
+
+ create_list(board, type, target, position)
+ end
+
+ return ServiceResponse.error(message: list.errors.full_messages) unless list.persisted?
+
+ ServiceResponse.success(payload: { list: list })
end
private
@@ -33,7 +37,7 @@ module Boards
def target(board)
strong_memoize(:target) do
- available_labels.find(params[:label_id])
+ available_labels.find_by(id: params[:label_id]) # rubocop: disable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/boards/lists/generate_service.rb b/app/services/boards/lists/generate_service.rb
index 4fbf1026019..d74320e92a3 100644
--- a/app/services/boards/lists/generate_service.rb
+++ b/app/services/boards/lists/generate_service.rb
@@ -7,7 +7,11 @@ module Boards
return false unless board.lists.movable.empty?
List.transaction do
- label_params.each { |params| create_list(board, params) }
+ label_params.each do |params|
+ response = create_list(board, params)
+
+ raise ActiveRecord::Rollback unless response.success?
+ end
end
true
diff --git a/app/services/ci/compare_codequality_reports_service.rb b/app/services/ci/compare_codequality_reports_service.rb
new file mode 100644
index 00000000000..20f5378f051
--- /dev/null
+++ b/app/services/ci/compare_codequality_reports_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Ci
+ class CompareCodequalityReportsService < CompareReportsBaseService
+ def comparer_class
+ Gitlab::Ci::Reports::CodequalityReportsComparer
+ end
+
+ def serializer_class
+ CodequalityReportsComparerSerializer
+ end
+
+ def get_report(pipeline)
+ pipeline&.codequality_reports
+ end
+ end
+end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index e3bab2de44e..dbe81521cfc 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -18,6 +18,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules,
Gitlab::Ci::Pipeline::Chain::Seed,
Gitlab::Ci::Pipeline::Chain::Limit::Size,
+ Gitlab::Ci::Pipeline::Chain::Limit::Deployments,
Gitlab::Ci::Pipeline::Chain::Validate::External,
Gitlab::Ci::Pipeline::Chain::Populate,
Gitlab::Ci::Pipeline::Chain::StopDryRun,
@@ -90,7 +91,9 @@ module Ci
# rubocop: enable Metrics/ParameterLists
def execute!(*args, &block)
- execute(*args, &block).tap do |pipeline|
+ source, params = args[0], Hash(args[1])
+
+ execute(source, **params, &block).tap do |pipeline|
unless pipeline.persisted?
raise CreateError, pipeline.full_error_messages
end
diff --git a/app/services/ci/list_config_variables_service.rb b/app/services/ci/list_config_variables_service.rb
index 4a5b3a92a2c..88dac514bb9 100644
--- a/app/services/ci/list_config_variables_service.rb
+++ b/app/services/ci/list_config_variables_service.rb
@@ -2,7 +2,26 @@
module Ci
class ListConfigVariablesService < ::BaseService
+ include ReactiveCaching
+
+ self.reactive_cache_key = ->(service) { [service.class.name, service.id] }
+ self.reactive_cache_work_type = :external_dependency
+ self.reactive_cache_worker_finder = ->(id, *_args) { from_cache(id) }
+
+ def self.from_cache(id)
+ project_id, user_id = id.split('-')
+
+ project = Project.find(project_id)
+ user = User.find(user_id)
+
+ new(project, user)
+ end
+
def execute(sha)
+ with_reactive_cache(sha) { |result| result }
+ end
+
+ def calculate_reactive_cache(sha)
config = project.ci_config_for(sha)
return {} unless config
@@ -12,5 +31,10 @@ module Ci
result.valid? ? result.variables_with_data : {}
end
+
+ # Required for ReactiveCaching, it is also used in `reactive_cache_worker_finder`
+ def id
+ "#{project.id}-#{current_user.id}"
+ end
end
end
diff --git a/app/services/ci/test_cases_service.rb b/app/services/ci/test_cases_service.rb
deleted file mode 100644
index 3139b567571..00000000000
--- a/app/services/ci/test_cases_service.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class TestCasesService
- MAX_TRACKABLE_FAILURES = 200
-
- def execute(build)
- return unless Feature.enabled?(:test_failure_history, build.project)
- return unless build.has_test_reports?
- return unless build.project.default_branch_or_master == build.ref
-
- test_suite = generate_test_suite_report(build)
-
- track_failures(build, test_suite)
- end
-
- private
-
- def generate_test_suite_report(build)
- build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new)
- end
-
- def track_failures(build, test_suite)
- return if test_suite.failed_count > MAX_TRACKABLE_FAILURES
-
- test_suite.failed.keys.each_slice(100) do |keys|
- Ci::TestCase.transaction do
- test_cases = Ci::TestCase.find_or_create_by_batch(build.project, keys)
- Ci::TestCaseFailure.insert_all(test_case_failures(test_cases, build))
- end
- end
- end
-
- def test_case_failures(test_cases, build)
- test_cases.map do |test_case|
- {
- test_case_id: test_case.id,
- build_id: build.id,
- failed_at: build.finished_at
- }
- end
- end
- end
-end
diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb
new file mode 100644
index 00000000000..99a2592ec06
--- /dev/null
+++ b/app/services/ci/test_failure_history_service.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module Ci
+ class TestFailureHistoryService
+ class Async
+ attr_reader :service
+
+ def initialize(service)
+ @service = service
+ end
+
+ def perform_if_needed
+ TestFailureHistoryWorker.perform_async(service.pipeline.id) if service.should_track_failures?
+ end
+ end
+
+ MAX_TRACKABLE_FAILURES = 200
+
+ attr_reader :pipeline
+ delegate :project, to: :pipeline
+
+ def initialize(pipeline)
+ @pipeline = pipeline
+ end
+
+ def execute
+ return unless should_track_failures?
+
+ track_failures
+ end
+
+ def should_track_failures?
+ return false unless Feature.enabled?(:test_failure_history, project)
+ return false unless project.default_branch_or_master == pipeline.ref
+
+ # 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
+ # 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
+ end
+
+ def async
+ Async.new(self)
+ end
+
+ private
+
+ def failed_builds
+ @failed_builds ||= pipeline.builds_with_failed_tests(limit: MAX_TRACKABLE_FAILURES + 1)
+ end
+
+ def track_failures
+ failed_test_cases = gather_failed_test_cases(failed_builds)
+
+ return if failed_test_cases.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)
+
+ Ci::TestCaseFailure.insert_all(failures)
+ end
+ end
+ end
+
+ def gather_failed_test_cases(failed_builds)
+ failed_builds.each_with_object({}) do |build, failed_test_cases|
+ test_suite = generate_test_suite!(build)
+ test_suite.failed.keys.each do |key|
+ failed_test_cases[key] = build
+ end
+ end
+ end
+
+ def generate_test_suite!(build)
+ # Returns an instance of Gitlab::Ci::Reports::TestSuite
+ 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]
+
+ {
+ test_case_id: test_case.id,
+ build_id: build.id,
+ failed_at: build.finished_at
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/ci/update_build_state_service.rb b/app/services/ci/update_build_state_service.rb
index fb67b0d2355..f01d41d9414 100644
--- a/app/services/ci/update_build_state_service.rb
+++ b/app/services/ci/update_build_state_service.rb
@@ -82,6 +82,10 @@ module Ci
unless checksum.valid?
metrics.increment_trace_operation(operation: :invalid)
+ if checksum.corrupted?
+ metrics.increment_trace_operation(operation: :corrupted)
+ end
+
next unless log_invalid_chunks?
::Gitlab::ErrorTracking.log_exception(InvalidTraceError.new,
@@ -89,7 +93,8 @@ module Ci
build_id: build.id,
state_crc32: checksum.state_crc32,
chunks_crc32: checksum.chunks_crc32,
- chunks_count: checksum.chunks_count
+ chunks_count: checksum.chunks_count,
+ chunks_corrupted: checksum.corrupted?
)
end
end
@@ -151,13 +156,21 @@ module Ci
end
def has_checksum?
- params.dig(:checksum).present?
+ trace_checksum.present?
end
def build_running?
build_state == 'running'
end
+ def trace_checksum
+ params.dig(:output, :checksum) || params.dig(:checksum)
+ end
+
+ def trace_bytesize
+ params.dig(:output, :bytesize)
+ end
+
def pending_state
strong_memoize(:pending_state) { ensure_pending_state }
end
@@ -166,7 +179,8 @@ module Ci
build_state = Ci::BuildPendingState.safe_find_or_create_by(
build_id: build.id,
state: params.fetch(:state),
- trace_checksum: params.fetch(:checksum),
+ trace_checksum: trace_checksum,
+ trace_bytesize: trace_bytesize,
failure_reason: params.dig(:failure_reason)
)
diff --git a/app/services/clusters/applications/prometheus_health_check_service.rb b/app/services/clusters/applications/prometheus_health_check_service.rb
index e609d9f0b7b..eda47f56e72 100644
--- a/app/services/clusters/applications/prometheus_health_check_service.rb
+++ b/app/services/clusters/applications/prometheus_health_check_service.rb
@@ -63,8 +63,10 @@ module Clusters
def send_notification(project)
notification_payload = build_notification_payload(project)
- token = project.alerts_service.data.token
- Projects::Alerting::NotifyService.new(project, nil, notification_payload).execute(token)
+ integration = project.alert_management_http_integrations.active.first
+
+ Projects::Alerting::NotifyService.new(project, notification_payload).execute(integration&.token, integration)
+
@logger.info(message: 'Successfully notified of Prometheus newly unhealthy', cluster_id: @cluster.id, project_id: project.id)
end
diff --git a/app/services/clusters/aws/authorize_role_service.rb b/app/services/clusters/aws/authorize_role_service.rb
index 188c4aebc5f..7ca20289bf7 100644
--- a/app/services/clusters/aws/authorize_role_service.rb
+++ b/app/services/clusters/aws/authorize_role_service.rb
@@ -29,7 +29,7 @@ module Clusters
rescue *ERRORS => e
Gitlab::ErrorTracking.track_exception(e)
- Response.new(:unprocessable_entity, {})
+ Response.new(:unprocessable_entity, response_details(e))
end
private
@@ -47,6 +47,28 @@ module Clusters
def credentials
Clusters::Aws::FetchCredentialsService.new(role).execute
end
+
+ def response_details(exception)
+ message =
+ case exception
+ when ::Aws::STS::Errors::AccessDenied
+ _("Access denied: %{error}") % { error: exception.message }
+ when ::Aws::STS::Errors::ServiceError
+ _("AWS service error: %{error}") % { error: exception.message }
+ when ActiveRecord::RecordNotFound
+ _("Error: Unable to find AWS role for current user")
+ when ActiveRecord::RecordInvalid
+ exception.message
+ when Clusters::Aws::FetchCredentialsService::MissingRoleError
+ _("Error: No AWS provision role found for user")
+ when ::Aws::Errors::MissingCredentialsError
+ _("Error: No AWS credentials were supplied")
+ else
+ _('An error occurred while authorizing your role')
+ end
+
+ { message: message }.compact
+ end
end
end
end
diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb
index 96abbb43969..497e676f549 100644
--- a/app/services/clusters/aws/fetch_credentials_service.rb
+++ b/app/services/clusters/aws/fetch_credentials_service.rb
@@ -30,10 +30,17 @@ module Clusters
attr_reader :provider, :region
def client
- ::Aws::STS::Client.new(credentials: gitlab_credentials, region: region)
+ ::Aws::STS::Client.new(**client_args)
+ end
+
+ def client_args
+ { region: region, credentials: gitlab_credentials }.compact
end
def gitlab_credentials
+ # These are not needed for IAM instance profiles
+ return unless access_key_id.present? && secret_access_key.present?
+
::Aws::Credentials.new(access_key_id, secret_access_key)
end
diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb
index a58e9aefcec..76d59cf2159 100644
--- a/app/services/concerns/exclusive_lease_guard.rb
+++ b/app/services/concerns/exclusive_lease_guard.rb
@@ -21,7 +21,7 @@ module ExclusiveLeaseGuard
lease = exclusive_lease.try_obtain
unless lease
- log_error("Cannot obtain an exclusive lease for #{self.class.name}. There must be another instance already in execution.")
+ log_error("Cannot obtain an exclusive lease for #{lease_key}. There must be another instance already in execution.")
return
end
diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb
index 4f4032e77b9..c1c93aa604e 100644
--- a/app/services/concerns/users/participable_service.rb
+++ b/app/services/concerns/users/participable_service.rb
@@ -8,10 +8,14 @@ module Users
attr_reader :noteable
end
+ private
+
def noteable_owner
return [] unless noteable && noteable.author.present?
- [user_as_hash(noteable.author)]
+ [noteable.author].tap do |users|
+ preload_status(users)
+ end
end
def participants_in_noteable
@@ -22,23 +26,29 @@ module Users
end
def sorted(users)
- users.uniq.to_a.compact.sort_by(&:username).map do |user|
- user_as_hash(user)
+ users.uniq.to_a.compact.sort_by(&:username).tap do |users|
+ preload_status(users)
end
end
def groups
- group_counts = GroupMember
- .of_groups(current_user.authorized_groups)
- .non_request
- .count_users_by_group_id
+ current_user.authorized_groups.with_route.sort_by(&:path)
+ end
- current_user.authorized_groups.with_route.sort_by(&:path).map do |group|
- group_as_hash(group, group_counts)
- end
+ def render_participants_as_hash(participants)
+ participants.map(&method(:participant_as_hash))
end
- private
+ def participant_as_hash(participant)
+ case participant
+ when Group
+ group_as_hash(participant)
+ when User
+ user_as_hash(participant)
+ else
+ participant
+ end
+ end
def user_as_hash(user)
{
@@ -46,12 +56,11 @@ module Users
username: user.username,
name: user.name,
avatar_url: user.avatar_url,
- availability: nil
+ availability: lazy_user_availability(user).itself # calling #itself to avoid returning a BatchLoader instance
}
- # Return nil for availability for now due to https://gitlab.com/gitlab-org/gitlab/-/issues/285442
end
- def group_as_hash(group, group_counts)
+ def group_as_hash(group)
{
type: group.class.name,
username: group.full_path,
@@ -61,5 +70,27 @@ module Users
mentionsDisabled: group.mentions_disabled
}
end
+
+ def group_counts
+ @group_counts ||= GroupMember
+ .of_groups(current_user.authorized_groups)
+ .non_request
+ .count_users_by_group_id
+ end
+
+ def preload_status(users)
+ users.each { |u| lazy_user_availability(u) }
+ end
+
+ def lazy_user_availability(user)
+ BatchLoader.for(user.id).batch do |user_ids, loader|
+ user_ids.each_slice(1_000) do |sliced_user_ids|
+ UserStatus
+ .select(:user_id, :availability)
+ .primary_key_in(sliced_user_ids)
+ .each { |status| loader.call(status.user_id, status.availability) }
+ end
+ end
+ end
end
end
diff --git a/app/services/container_expiration_policies/cleanup_service.rb b/app/services/container_expiration_policies/cleanup_service.rb
index f2bc2beab63..4719c99af6d 100644
--- a/app/services/container_expiration_policies/cleanup_service.rb
+++ b/app/services/container_expiration_policies/cleanup_service.rb
@@ -20,7 +20,8 @@ module ContainerExpirationPolicies
if result[:status] == :success
repository.update!(
expiration_policy_cleanup_status: :cleanup_unscheduled,
- expiration_policy_started_at: nil
+ expiration_policy_started_at: nil,
+ expiration_policy_completed_at: Time.zone.now
)
success(:finished)
else
diff --git a/app/services/dependency_proxy/auth_token_service.rb b/app/services/dependency_proxy/auth_token_service.rb
new file mode 100644
index 00000000000..16279ed12b0
--- /dev/null
+++ b/app/services/dependency_proxy/auth_token_service.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class AuthTokenService < DependencyProxy::BaseService
+ attr_reader :token
+
+ def initialize(token)
+ @token = token
+ end
+
+ def execute
+ JSONWebToken::HMACToken.decode(token, ::Auth::DependencyProxyAuthenticationService.secret).first
+ end
+
+ class << self
+ def decoded_token_payload(token)
+ self.new(token).execute
+ end
+ end
+ end
+end
diff --git a/app/services/dependency_proxy/base_service.rb b/app/services/dependency_proxy/base_service.rb
index 1b2d4b14a27..944877fd5f9 100644
--- a/app/services/dependency_proxy/base_service.rb
+++ b/app/services/dependency_proxy/base_service.rb
@@ -2,6 +2,16 @@
module DependencyProxy
class BaseService < ::BaseService
+ class DownloadError < StandardError
+ attr_reader :http_status
+
+ def initialize(message, http_status)
+ @http_status = http_status
+
+ super(message)
+ end
+ end
+
private
def registry
diff --git a/app/services/dependency_proxy/download_blob_service.rb b/app/services/dependency_proxy/download_blob_service.rb
index 3c690683bf6..b3548c8a126 100644
--- a/app/services/dependency_proxy/download_blob_service.rb
+++ b/app/services/dependency_proxy/download_blob_service.rb
@@ -2,16 +2,6 @@
module DependencyProxy
class DownloadBlobService < DependencyProxy::BaseService
- class DownloadError < StandardError
- attr_reader :http_status
-
- def initialize(message, http_status)
- @http_status = http_status
-
- super(message)
- end
- end
-
def initialize(image, blob_sha, token)
@image = image
@blob_sha = blob_sha
diff --git a/app/services/dependency_proxy/find_or_create_manifest_service.rb b/app/services/dependency_proxy/find_or_create_manifest_service.rb
new file mode 100644
index 00000000000..6b46f5e4c59
--- /dev/null
+++ b/app/services/dependency_proxy/find_or_create_manifest_service.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class FindOrCreateManifestService < DependencyProxy::BaseService
+ def initialize(group, image, tag, token)
+ @group = group
+ @image = image
+ @tag = tag
+ @token = token
+ @file_name = "#{@image}:#{@tag}.json"
+ @manifest = nil
+ end
+
+ def execute
+ @manifest = @group.dependency_proxy_manifests
+ .find_or_initialize_by_file_name(@file_name)
+
+ head_result = DependencyProxy::HeadManifestService.new(@image, @tag, @token).execute
+
+ return success(manifest: @manifest) if cached_manifest_matches?(head_result)
+
+ pull_new_manifest
+ respond
+ rescue Timeout::Error, *Gitlab::HTTP::HTTP_ERRORS
+ respond
+ end
+
+ private
+
+ def pull_new_manifest
+ DependencyProxy::PullManifestService.new(@image, @tag, @token).execute_with_manifest do |new_manifest|
+ @manifest.update!(
+ digest: new_manifest[:digest],
+ file: new_manifest[:file],
+ size: new_manifest[:file].size
+ )
+ end
+ end
+
+ def cached_manifest_matches?(head_result)
+ @manifest && @manifest.digest == head_result[:digest]
+ end
+
+ def respond
+ if @manifest.persisted?
+ success(manifest: @manifest)
+ else
+ error('Failed to download the manifest from the external registry', 503)
+ end
+ end
+ end
+end
diff --git a/app/services/dependency_proxy/head_manifest_service.rb b/app/services/dependency_proxy/head_manifest_service.rb
new file mode 100644
index 00000000000..87d9c417c98
--- /dev/null
+++ b/app/services/dependency_proxy/head_manifest_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ class HeadManifestService < DependencyProxy::BaseService
+ def initialize(image, tag, token)
+ @image = image
+ @tag = tag
+ @token = token
+ end
+
+ def execute
+ response = Gitlab::HTTP.head(manifest_url, headers: auth_headers)
+
+ if response.success?
+ success(digest: response.headers['docker-content-digest'])
+ else
+ error(response.body, response.code)
+ end
+ rescue Timeout::Error => exception
+ error(exception.message, 599)
+ end
+
+ private
+
+ def manifest_url
+ registry.manifest_url(@image, @tag)
+ end
+ end
+end
diff --git a/app/services/dependency_proxy/pull_manifest_service.rb b/app/services/dependency_proxy/pull_manifest_service.rb
index fc54ef85c96..5c804489fd1 100644
--- a/app/services/dependency_proxy/pull_manifest_service.rb
+++ b/app/services/dependency_proxy/pull_manifest_service.rb
@@ -8,13 +8,25 @@ module DependencyProxy
@token = token
end
- def execute
+ def execute_with_manifest
+ raise ArgumentError, 'Block must be provided' unless block_given?
+
response = Gitlab::HTTP.get(manifest_url, headers: auth_headers)
if response.success?
- success(manifest: response.body)
+ file = Tempfile.new
+
+ begin
+ file.write(response)
+ file.flush
+
+ yield(success(file: file, digest: response.headers['docker-content-digest']))
+ ensure
+ file.close
+ file.unlink
+ end
else
- error(response.body, response.code)
+ yield(error(response.body, response.code))
end
rescue Timeout::Error => exception
error(exception.message, 599)
diff --git a/app/services/environments/canary_ingress/update_service.rb b/app/services/environments/canary_ingress/update_service.rb
new file mode 100644
index 00000000000..474c3de23d9
--- /dev/null
+++ b/app/services/environments/canary_ingress/update_service.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+module Environments
+ module CanaryIngress
+ class UpdateService < ::BaseService
+ def execute_async(environment)
+ result = validate(environment)
+
+ return result unless result[:status] == :success
+
+ Environments::CanaryIngress::UpdateWorker.perform_async(environment.id, params)
+
+ success
+ end
+
+ # This method actually executes the PATCH request to Kubernetes,
+ # that is used by internal processes i.e. sidekiq worker.
+ # You should always use `execute_async` to properly validate user's requests.
+ def execute(environment)
+ canary_ingress = environment.ingresses&.find(&:canary?)
+
+ unless canary_ingress.present?
+ return error(_('Canary Ingress does not exist in the environment.'))
+ end
+
+ if environment.patch_ingress(canary_ingress, patch_data)
+ success
+ else
+ error(_('Failed to update the Canary Ingress.'), :bad_request)
+ end
+ end
+
+ private
+
+ def validate(environment)
+ unless Feature.enabled?(:canary_ingress_weight_control, environment.project, default_enabled: true)
+ return error(_("Feature flag is not enabled on the environment's project."))
+ end
+
+ unless can?(current_user, :update_environment, environment)
+ return error(_('You do not have permission to update the environment.'))
+ end
+
+ unless params[:weight].is_a?(Integer) && (0..100).cover?(params[:weight])
+ return error(_('Canary weight must be specified and valid range (0..100).'))
+ end
+
+ if environment.has_running_deployments?
+ return error(_('There are running deployments on the environment. Please retry later.'))
+ end
+
+ if ::Gitlab::ApplicationRateLimiter.throttled?(:update_environment_canary_ingress, scope: [environment])
+ return error(_("This environment's canary ingress has been updated recently. Please retry later."))
+ end
+
+ success
+ end
+
+ def patch_data
+ {
+ metadata: {
+ annotations: {
+ Gitlab::Kubernetes::Ingress::ANNOTATION_KEY_CANARY_WEIGHT => params[:weight].to_s
+ }
+ }
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb
index b4ca90f7aae..de3a55d10fc 100644
--- a/app/services/feature_flags/create_service.rb
+++ b/app/services/feature_flags/create_service.rb
@@ -5,7 +5,6 @@ module FeatureFlags
def execute
return error('Access Denied', 403) unless can_create?
return error('Version is invalid', :bad_request) unless valid_version?
- return error('New version feature flags are not enabled for this project', :bad_request) unless flag_version_enabled?
ActiveRecord::Base.transaction do
feature_flag = project.operations_feature_flags.new(params)
@@ -40,13 +39,5 @@ module FeatureFlags
def valid_version?
!params.key?(:version) || Operations::FeatureFlag.versions.key?(params[:version])
end
-
- def flag_version_enabled?
- params[:version] != 'new_version_flag' || new_version_feature_flags_enabled?
- end
-
- def new_version_feature_flags_enabled?
- ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
- end
end
end
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index ea5b2f401b3..1ca1bfa0c05 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -135,11 +135,12 @@ module Git
# We only need the last commit for the event push, and we don't
# need the full deltas either.
@event_push_data ||= Gitlab::DataBuilder::Push.build(
- push_data_params(commits: commits.last, with_changed_files: false))
+ **push_data_params(commits: commits.last, with_changed_files: false)
+ )
end
def push_data
- @push_data ||= Gitlab::DataBuilder::Push.build(push_data_params(commits: limited_commits))
+ @push_data ||= Gitlab::DataBuilder::Push.build(**push_data_params(commits: limited_commits))
# Dependent code may modify the push data, so return a duplicate each time
@push_data.dup
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index d00ca83441a..4edcff0e3d0 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -118,7 +118,7 @@ module Git
commits_to_sync = limited_commits.select { |commit| Atlassian::JiraIssueKeyExtractor.has_keys?(commit.safe_message) }.map(&:sha)
if branch_to_sync || commits_to_sync.any?
- JiraConnect::SyncBranchWorker.perform_async(project.id, branch_to_sync, commits_to_sync)
+ JiraConnect::SyncBranchWorker.perform_async(project.id, branch_to_sync, commits_to_sync, Atlassian::JiraConnect::Client.generate_update_sequence_id)
end
end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 016c31cbccc..52600f5b88f 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -34,7 +34,7 @@ module Groups
if @group.save
@group.add_owner(current_user)
@group.create_namespace_settings
- Service.create_from_active_default_integrations(@group, :group_id) if Feature.enabled?(:group_level_integrations, default_enabled: true)
+ Service.create_from_active_default_integrations(@group, :group_id)
end
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index aad574aeaf5..e800e546a45 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -28,9 +28,11 @@ module Groups
Group.transaction do
update_group_attributes
ensure_ownership
+ update_integrations
end
post_update_hooks(@updated_project_ids)
+ propagate_integrations
true
end
@@ -196,6 +198,17 @@ module Groups
raise TransferError, result[:message] unless result[:status] == :success
end
end
+
+ def update_integrations
+ @group.services.inherit.delete_all
+ Service.create_from_active_default_integrations(@group, :group_id)
+ end
+
+ def propagate_integrations
+ @group.services.inherit.each do |integration|
+ PropagateIntegrationWorker.perform_async(integration.id)
+ end
+ end
end
end
diff --git a/app/services/import/bitbucket_server_service.rb b/app/services/import/bitbucket_server_service.rb
index 86e8215821e..cdb23370ddc 100644
--- a/app/services/import/bitbucket_server_service.rb
+++ b/app/services/import/bitbucket_server_service.rb
@@ -81,11 +81,9 @@ module Import
def blocked_url?
Gitlab::UrlBlocker.blocked_url?(
url,
- {
- allow_localhost: allow_local_requests?,
- allow_local_network: allow_local_requests?,
- schemes: %w(http https)
- }
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
)
end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
index 948dba2d206..847c5eb4397 100644
--- a/app/services/import/github_service.rb
+++ b/app/services/import/github_service.rb
@@ -31,9 +31,8 @@ module Import
project_name,
target_namespace,
current_user,
- access_params,
- type: provider
- ).execute(extra_project_attrs)
+ type: provider,
+ **access_params).execute(extra_project_attrs)
end
def repo
@@ -71,11 +70,9 @@ module Import
def blocked_url?
Gitlab::UrlBlocker.blocked_url?(
url,
- {
- allow_localhost: allow_local_requests?,
- allow_local_network: allow_local_requests?,
- schemes: %w(http https)
- }
+ allow_localhost: allow_local_requests?,
+ allow_local_network: allow_local_requests?,
+ schemes: %w(http https)
)
end
diff --git a/app/services/incident_management/incidents/update_severity_service.rb b/app/services/incident_management/incidents/update_severity_service.rb
index 5b150f3f02e..faa9277c469 100644
--- a/app/services/incident_management/incidents/update_severity_service.rb
+++ b/app/services/incident_management/incidents/update_severity_service.rb
@@ -12,7 +12,7 @@ module IncidentManagement
end
def execute
- return unless issuable.incident?
+ return unless issuable.supports_severity?
update_severity!
add_system_note
diff --git a/app/services/integrations/test/project_service.rb b/app/services/integrations/test/project_service.rb
index 39471d373f9..d72ca928c34 100644
--- a/app/services/integrations/test/project_service.rb
+++ b/app/services/integrations/test/project_service.rb
@@ -16,9 +16,7 @@ module Integrations
def data
strong_memoize(:data) do
- next pipeline_events_data if integration.is_a?(::PipelinesEmailService)
-
- case event
+ case event || integration.default_test_event
when 'push', 'tag_push'
push_events_data
when 'note', 'confidential_note'
@@ -37,8 +35,6 @@ module Integrations
deployment_events_data
when 'release'
releases_events_data
- else
- push_events_data
end
end
end
diff --git a/app/services/issuable/import_csv/base_service.rb b/app/services/issuable/import_csv/base_service.rb
index bf5f643a51b..5a2665285de 100644
--- a/app/services/issuable/import_csv/base_service.rb
+++ b/app/services/issuable/import_csv/base_service.rb
@@ -38,20 +38,19 @@ module Issuable
def with_csv_lines
csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
- verify_headers!(csv_data)
+ validate_headers_presence!(csv_data.lines.first)
- csv_parsing_params = {
+ CSV.new(
+ csv_data,
col_sep: detect_col_sep(csv_data.lines.first),
headers: true,
header_converters: :symbol
- }
-
- CSV.new(csv_data, csv_parsing_params).each.with_index(2)
+ ).each.with_index(2)
end
- def verify_headers!(data)
- headers = data.lines.first.downcase
- return if headers.include?('title') && headers.include?('description')
+ def validate_headers_presence!(headers)
+ headers.downcase! if headers
+ return if headers && headers.include?('title') && headers.include?('description')
raise CSV::MalformedCSVError
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 978ea6fe9bc..25f319da03b 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -73,22 +73,6 @@ module Issues
Milestones::IssuesCountService.new(milestone).delete_cache
end
-
- # Applies label "incident" (creates it if missing) to incident issues.
- # Please use in "after" hooks only to ensure we are not appyling
- # labels prematurely.
- def add_incident_label(issue)
- return unless issue.incident?
-
- label = ::IncidentManagement::CreateIncidentLabelService
- .new(project, current_user)
- .execute
- .payload[:label]
-
- return if issue.label_ids.include?(label.id)
-
- issue.labels << label
- end
end
end
diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb
new file mode 100644
index 00000000000..789da312958
--- /dev/null
+++ b/app/services/issues/clone_service.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module Issues
+ class CloneService < Issuable::Clone::BaseService
+ CloneError = Class.new(StandardError)
+
+ def execute(issue, target_project, with_notes: false)
+ @target_project = target_project
+ @with_notes = with_notes
+
+ unless issue.can_clone?(current_user, target_project)
+ raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!')
+ end
+
+ if target_project.pending_delete?
+ raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.')
+ end
+
+ super(issue, target_project)
+
+ notify_participants
+
+ queue_copy_designs
+
+ new_entity
+ end
+
+ private
+
+ attr_reader :target_project
+ attr_reader :with_notes
+
+ def update_new_entity
+ # we don't call `super` because we want to be able to decide whether or not to copy all comments over.
+ update_new_entity_description
+ update_new_entity_attributes
+ copy_award_emoji
+ copy_notes if with_notes
+ end
+
+ def update_old_entity
+ # no-op
+ # The base_service closes the old issue, we don't want that, so we override here so nothing happens.
+ end
+
+ def create_new_entity
+ new_params = {
+ id: nil,
+ iid: nil,
+ project: target_project,
+ author: current_user,
+ assignee_ids: original_entity.assignee_ids
+ }
+
+ new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
+
+ # Skip creation of system notes for existing attributes of the issue. The system notes of the old
+ # issue are copied over so we don't want to end up with duplicate notes.
+ CreateService.new(target_project, current_user, new_params).execute(skip_system_notes: true)
+ end
+
+ def queue_copy_designs
+ return unless original_entity.designs.present?
+
+ response = DesignManagement::CopyDesignCollection::QueueService.new(
+ current_user,
+ original_entity,
+ new_entity
+ ).execute
+
+ log_error(response.message) if response.error?
+ end
+
+ def notify_participants
+ notification_service.async.issue_cloned(original_entity, new_entity, current_user)
+ end
+
+ def add_note_from
+ SystemNoteService.noteable_cloned(new_entity, target_project,
+ original_entity, current_user,
+ direction: :from)
+ end
+
+ def add_note_to
+ SystemNoteService.noteable_cloned(original_entity, old_project,
+ new_entity, current_user,
+ direction: :to)
+ end
+ end
+end
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index fb7683f940d..44de8eb6389 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -49,6 +49,22 @@ module Issues
def user_agent_detail_service
UserAgentDetailService.new(@issue, @request)
end
+
+ # Applies label "incident" (creates it if missing) to incident issues.
+ # For use in "after" hooks only to ensure we are not appyling
+ # labels prematurely.
+ def add_incident_label(issue)
+ return unless issue.incident?
+
+ label = ::IncidentManagement::CreateIncidentLabelService
+ .new(project, current_user)
+ .execute
+ .payload[:label]
+
+ return if issue.label_ids.include?(label.id)
+
+ issue.labels << label
+ end
end
end
diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb
index 1dcdfb9faea..8f513632929 100644
--- a/app/services/issues/export_csv_service.rb
+++ b/app/services/issues/export_csv_service.rb
@@ -34,7 +34,7 @@ module Issues
private
def associations_to_preload
- %i(author assignees timelogs)
+ %i(author assignees timelogs milestone)
end
def header_to_value_hash
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index b9832400302..127ed04cf51 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -9,7 +9,7 @@ module Issues
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
- move_issue_to_new_project(issue) || update_task_event(issue) || update(issue)
+ move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
end
def update(issue)
@@ -34,7 +34,6 @@ module Issues
end
def after_update(issue)
- add_incident_label(issue)
IssuesChannel.broadcast_to(issue, event: 'updated') if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project)
end
@@ -127,6 +126,18 @@ module Issues
private
+ def clone_issue(issue)
+ target_project = params.delete(:target_clone_project)
+ with_notes = params.delete(:clone_with_notes)
+
+ return unless target_project &&
+ issue.can_clone?(current_user, target_project)
+
+ # we've pre-empted this from running in #execute, so let's go ahead and update the Issue now.
+ update(issue)
+ Issues::CloneService.new(project, current_user).execute(issue, target_project, with_notes: with_notes)
+ end
+
def create_merge_request_from_quick_action
create_merge_request_params = params.delete(:create_merge_request)
return unless create_merge_request_params
diff --git a/app/services/jira/requests/base.rb b/app/services/jira/requests/base.rb
index 4ed8df0f235..098aae9284c 100644
--- a/app/services/jira/requests/base.rb
+++ b/app/services/jira/requests/base.rb
@@ -18,14 +18,19 @@ module Jira
request
end
+ # We have to add the context_path here because the Jira client is not taking it into account
def base_api_url
- "/rest/api/#{api_version}"
+ "#{context_path}/rest/api/#{api_version}"
end
private
attr_reader :jira_service, :project
+ def context_path
+ client.options[:context_path].to_s
+ end
+
# override this method in the specific request class implementation if a differnt API version is required
def api_version
JIRA_API_VERSION
diff --git a/app/services/jira_connect/sync_service.rb b/app/services/jira_connect/sync_service.rb
index f8855fb6deb..b2af284f1f0 100644
--- a/app/services/jira_connect/sync_service.rb
+++ b/app/services/jira_connect/sync_service.rb
@@ -6,13 +6,15 @@ module JiraConnect
self.project = project
end
- def execute(commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
- JiraConnectInstallation.for_project(project).each do |installation|
+ # Parameters: see Atlassian::JiraConnect::Client#send_info
+ # Includes: update_sequence_id, commits, branches, merge_requests, pipelines
+ def execute(**args)
+ JiraConnectInstallation.for_project(project).flat_map do |installation|
client = Atlassian::JiraConnect::Client.new(installation.base_url, installation.shared_secret)
- response = client.store_dev_info(project: project, commits: commits, branches: branches, merge_requests: merge_requests, update_sequence_id: update_sequence_id)
+ responses = client.send_info(project: project, **args)
- log_response(response)
+ responses.each { |r| log_response(r) }
end
end
@@ -29,7 +31,7 @@ module JiraConnect
jira_response: response&.to_json
}
- if response && response['errorMessages']
+ if response && (response['errorMessages'] || response['rejectedBuilds'].present?)
logger.error(message)
else
logger.info(message)
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 088e6f031c8..3588cda180f 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -38,6 +38,8 @@ module Members
end
end
+ enqueue_onboarding_progress_action(source) if members.size > errors.size
+
return success unless errors.any?
error(errors.to_sentence)
@@ -50,6 +52,10 @@ module Members
limit && limit < 0 ? nil : limit
end
+
+ def enqueue_onboarding_progress_action(source)
+ Namespaces::OnboardingUserAddedWorker.perform_async(source.id)
+ end
end
end
diff --git a/app/services/members/invitation_reminder_email_service.rb b/app/services/members/invitation_reminder_email_service.rb
index e589cdc2fa3..688618ec4b4 100644
--- a/app/services/members/invitation_reminder_email_service.rb
+++ b/app/services/members/invitation_reminder_email_service.rb
@@ -14,8 +14,6 @@ module Members
end
def execute
- return unless experiment_enabled?
-
reminder_index = days_on_which_to_send_reminders.index(days_after_invitation_sent)
return unless reminder_index
@@ -24,10 +22,6 @@ module Members
private
- def experiment_enabled?
- Gitlab::Experimentation.enabled_for_attribute?(:invitation_reminders, invitation.invite_email)
- end
-
def days_after_invitation_sent
(Date.today - invitation.created_at.to_date).to_i
end
diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb
index f0c85ae03c9..fbb9d5fa9dc 100644
--- a/app/services/merge_requests/after_create_service.rb
+++ b/app/services/merge_requests/after_create_service.rb
@@ -11,6 +11,8 @@ module MergeRequests
merge_request.diffs(include_stats: false).write_cache
merge_request.create_cross_references!(current_user)
+
+ NamespaceOnboardingAction.create_action(merge_request.target_project.namespace, :merge_request_created)
end
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index aa591312c6a..265b211066e 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -58,7 +58,7 @@ module MergeRequests
return unless project.jira_subscription_exists?
if Atlassian::JiraIssueKeyExtractor.has_keys?(merge_request.title, merge_request.description)
- JiraConnect::SyncMergeRequestWorker.perform_async(merge_request.id)
+ JiraConnect::SyncMergeRequestWorker.perform_async(merge_request.id, Atlassian::JiraConnect::Client.generate_update_sequence_id)
end
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 8c069ea5bb0..bff7a43dd7b 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -11,7 +11,7 @@ module MergeRequests
params.delete(:target_project_id)
params.delete(:source_branch)
- if merge_request.closed_without_fork?
+ if merge_request.closed_or_merged_without_fork?
params.delete(:target_branch)
params.delete(:force_remove_source_branch)
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index b2826b5c905..9fffb6c372b 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -67,7 +67,7 @@ module Notes
track_event(note, current_user)
if Feature.enabled?(:notes_create_service_tracking, project)
- Gitlab::Tracking.event('Notes::CreateService', 'execute', tracking_data_for(note))
+ Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note))
end
if note.for_merge_request? && note.diff_note? && note.start_of_discussion?
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index 85113d3ca22..4ff462191fe 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -353,7 +353,7 @@ class NotificationService
issue = note.noteable
support_bot = User.support_bot
- return unless issue.service_desk_reply_to.present?
+ return unless issue.external_author.present?
return unless issue.project.service_desk_enabled?
return if note.author == support_bot
return unless issue.subscribed?(support_bot, issue.project)
@@ -380,6 +380,10 @@ class NotificationService
end
end
+ def user_admin_rejection(name, email)
+ mailer.user_admin_rejection_email(name, email).deliver_later
+ end
+
# Members
def new_access_request(member)
return true unless member.notifiable?(:subscription)
@@ -500,6 +504,16 @@ class NotificationService
end
end
+ def issue_cloned(issue, new_issue, current_user)
+ recipients = NotificationRecipients::BuildService.build_recipients(issue, current_user, action: 'cloned')
+
+ recipients.map do |recipient|
+ email = mailer.issue_cloned_email(recipient.user, issue, new_issue, current_user, recipient.reason)
+ email.deliver_later
+ email
+ end
+ end
+
def project_exported(project, current_user)
return true unless notifiable?(current_user, :mention, project: project)
diff --git a/app/services/onboarding_progress_service.rb b/app/services/onboarding_progress_service.rb
new file mode 100644
index 00000000000..ebe7caabdef
--- /dev/null
+++ b/app/services/onboarding_progress_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class OnboardingProgressService
+ def initialize(namespace)
+ @namespace = namespace.root_ancestor
+ end
+
+ def execute(action:)
+ NamespaceOnboardingAction.create_action(@namespace, action)
+ end
+end
diff --git a/app/services/packages/composer/create_package_service.rb b/app/services/packages/composer/create_package_service.rb
index 2d2f1568187..0f5429f667e 100644
--- a/app/services/packages/composer/create_package_service.rb
+++ b/app/services/packages/composer/create_package_service.rb
@@ -16,6 +16,8 @@ module Packages
composer_json: composer_json
})
end
+
+ created_package
end
private
diff --git a/app/services/packages/conan/create_package_file_service.rb b/app/services/packages/conan/create_package_file_service.rb
index 2db5c4e507b..1bde9606492 100644
--- a/app/services/packages/conan/create_package_file_service.rb
+++ b/app/services/packages/conan/create_package_file_service.rb
@@ -12,7 +12,7 @@ module Packages
end
def execute
- package.package_files.create!(
+ package_file = package.package_files.build(
file: file,
size: params['file.size'],
file_name: params[:file_name],
@@ -25,6 +25,13 @@ module Packages
conan_file_type: params[:conan_file_type]
}
)
+
+ if params[:build].present?
+ package_file.package_file_build_infos << package_file.package_file_build_infos.build(pipeline: params[:build].pipeline)
+ end
+
+ package_file.save!
+ package_file
end
end
end
diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb
index c4492389da9..f0328ceb08a 100644
--- a/app/services/packages/create_event_service.rb
+++ b/app/services/packages/create_event_service.rb
@@ -4,7 +4,11 @@ module Packages
class CreateEventService < BaseService
def execute
if Feature.enabled?(:collect_package_events_redis) && redis_event_name
- ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(current_user.id, redis_event_name)
+ if guest?
+ ::Gitlab::UsageDataCounters::GuestPackageEventCounter.count(redis_event_name)
+ else
+ ::Gitlab::UsageDataCounters::HLLRedisCounter.track_event(current_user.id, redis_event_name)
+ end
end
if Feature.enabled?(:collect_package_events) && Gitlab::Database.read_write?
@@ -45,5 +49,9 @@ module Packages
:guest
end
end
+
+ def guest?
+ originator_type == :guest
+ end
end
end
diff --git a/app/services/packages/create_package_service.rb b/app/services/packages/create_package_service.rb
index e3b0ad218e2..fcf252cf971 100644
--- a/app/services/packages/create_package_service.rb
+++ b/app/services/packages/create_package_service.rb
@@ -8,9 +8,9 @@ module Packages
project
.packages
.with_package_type(package_type)
- .safe_find_or_create_by!(name: name, version: version) do |pkg|
- pkg.creator = package_creator
- yield pkg if block_given?
+ .safe_find_or_create_by!(name: name, version: version) do |package|
+ package.creator = package_creator
+ add_build_info(package)
end
end
@@ -18,7 +18,9 @@ module Packages
project
.packages
.with_package_type(package_type)
- .create!(package_attrs(attrs))
+ .create!(package_attrs(attrs)) do |package|
+ add_build_info(package)
+ end
end
private
@@ -34,5 +36,11 @@ module Packages
def package_creator
current_user if current_user.is_a?(User)
end
+
+ def add_build_info(package)
+ if params[:build].present?
+ package.build_infos.new(pipeline: params[:build].pipeline)
+ end
+ end
end
end
diff --git a/app/services/packages/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb
index f25e8b0ae56..b14b1c193ec 100644
--- a/app/services/packages/generic/create_package_file_service.rb
+++ b/app/services/packages/generic/create_package_file_service.rb
@@ -18,9 +18,12 @@ module Packages
build: params[:build]
}
- ::Packages::Generic::FindOrCreatePackageService
+ package = ::Packages::Generic::FindOrCreatePackageService
.new(project, current_user, package_params)
.execute
+
+ package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present?
+ package
end
def create_package_file(package)
diff --git a/app/services/packages/generic/find_or_create_package_service.rb b/app/services/packages/generic/find_or_create_package_service.rb
index 97f774a836b..0a6099e4d35 100644
--- a/app/services/packages/generic/find_or_create_package_service.rb
+++ b/app/services/packages/generic/find_or_create_package_service.rb
@@ -4,11 +4,7 @@ module Packages
module Generic
class FindOrCreatePackageService < ::Packages::CreatePackageService
def execute
- find_or_create_package!(::Packages::Package.package_types['generic']) do |package|
- if params[:build].present?
- package.build_infos.new(pipeline: params[:build].pipeline)
- end
- end
+ find_or_create_package!(::Packages::Package.package_types['generic'])
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 a2a61ff8d93..f598b5e7cd4 100644
--- a/app/services/packages/maven/find_or_create_package_service.rb
+++ b/app/services/packages/maven/find_or_create_package_service.rb
@@ -46,7 +46,7 @@ module Packages
.execute
end
- package.build_infos.create!(pipeline: params[:build].pipeline) if params[:build].present?
+ package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present?
package
end
diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb
index c4b75348bba..22396eb7687 100644
--- a/app/services/packages/npm/create_package_service.rb
+++ b/app/services/packages/npm/create_package_service.rb
@@ -17,10 +17,6 @@ module Packages
def create_npm_package!
package = create_package!(:npm, name: name, version: version)
- if build.present?
- package.build_infos.create!(pipeline: build.pipeline)
- end
-
::Packages::CreatePackageFileService.new(package, file_params).execute
::Packages::CreateDependencyService.new(package, package_dependencies).execute
::Packages::Npm::CreateTagService.new(package, dist_tag).execute
@@ -50,10 +46,6 @@ module Packages
params[:versions][version]
end
- def build
- params[:build]
- end
-
def dist_tag
params['dist-tags'].each_key.first
end
diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb
index c49efca0fc5..cb8d9559dc9 100644
--- a/app/services/packages/pypi/create_package_service.rb
+++ b/app/services/packages/pypi/create_package_service.rb
@@ -19,6 +19,8 @@ module Packages
Packages::Pypi::Metadatum.upsert(meta.attributes)
::Packages::CreatePackageFileService.new(created_package, file_params).execute
+
+ created_package
end
end
@@ -32,6 +34,7 @@ module Packages
def file_params
{
+ build: params[:build],
file: params[:content],
file_name: params[:content].original_filename,
file_md5: params[:md5_digest],
diff --git a/app/services/pages/legacy_storage_lease.rb b/app/services/pages/legacy_storage_lease.rb
new file mode 100644
index 00000000000..3f42fc8c63b
--- /dev/null
+++ b/app/services/pages/legacy_storage_lease.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Pages
+ module LegacyStorageLease
+ extend ActiveSupport::Concern
+
+ include ::ExclusiveLeaseGuard
+
+ LEASE_TIMEOUT = 1.hour
+
+ # override method from exclusive lease guard to guard it by feature flag
+ # TODO: just remove this method after testing this in production
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/282464
+ def try_obtain_lease
+ return yield unless Feature.enabled?(:pages_use_legacy_storage_lease, project, default_enabled: true)
+
+ super
+ end
+
+ def lease_key
+ "pages_legacy_storage:#{project.id}"
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+ end
+end
diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb
new file mode 100644
index 00000000000..a27ad5fda46
--- /dev/null
+++ b/app/services/pages/zip_directory_service.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module Pages
+ class ZipDirectoryService
+ InvalidArchiveError = Class.new(RuntimeError)
+ InvalidEntryError = Class.new(RuntimeError)
+
+ PUBLIC_DIR = 'public'
+
+ def initialize(input_dir)
+ @input_dir = File.realpath(input_dir)
+ @output_file = File.join(@input_dir, "@migrated.zip") # '@' to avoid any name collision with groups or projects
+ end
+
+ def execute
+ FileUtils.rm_f(@output_file)
+
+ count = 0
+ ::Zip::File.open(@output_file, ::Zip::File::CREATE) do |zipfile|
+ write_entry(zipfile, PUBLIC_DIR)
+ count = zipfile.entries.count
+ end
+
+ [@output_file, count]
+ end
+
+ private
+
+ def write_entry(zipfile, zipfile_path)
+ disk_file_path = File.join(@input_dir, zipfile_path)
+
+ unless valid_path?(disk_file_path)
+ # archive without public directory is completelly unusable
+ raise InvalidArchiveError if zipfile_path == PUBLIC_DIR
+
+ # archive with invalid entry will just have this entry missing
+ raise InvalidEntryError
+ end
+
+ case File.lstat(disk_file_path).ftype
+ when 'directory'
+ recursively_zip_directory(zipfile, disk_file_path, zipfile_path)
+ when 'file', 'link'
+ zipfile.add(zipfile_path, disk_file_path)
+ else
+ raise InvalidEntryError
+ end
+ rescue InvalidEntryError => e
+ Gitlab::ErrorTracking.track_exception(e, input_dir: @input_dir, disk_file_path: disk_file_path)
+ end
+
+ def recursively_zip_directory(zipfile, disk_file_path, zipfile_path)
+ zipfile.mkdir(zipfile_path)
+
+ entries = Dir.entries(disk_file_path) - %w[. ..]
+ entries = entries.map { |entry| File.join(zipfile_path, entry) }
+
+ write_entries(zipfile, entries)
+ end
+
+ def write_entries(zipfile, entries)
+ entries.each do |zipfile_path|
+ write_entry(zipfile, zipfile_path)
+ end
+ end
+
+ # that should never happen, but we want to be safer
+ # in theory without this we would allow to use symlinks
+ # to pack any directory on disk
+ # it isn't possible because SafeZip doesn't extract such archives
+ def valid_path?(disk_file_path)
+ realpath = File.realpath(disk_file_path)
+
+ realpath == File.join(@input_dir, PUBLIC_DIR) ||
+ realpath.start_with?(File.join(@input_dir, PUBLIC_DIR + "/"))
+ # happens if target of symlink isn't there
+ rescue => e
+ Gitlab::ErrorTracking.track_exception(e, input_dir: @input_dir, disk_file_path: disk_file_path)
+
+ false
+ end
+ end
+end
diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb
index 79b613f6a88..bd9588844ad 100644
--- a/app/services/post_receive_service.rb
+++ b/app/services/post_receive_service.rb
@@ -40,6 +40,8 @@ class PostReceiveService
response.add_basic_message(redirect_message)
response.add_basic_message(project_created_message)
+
+ record_onboarding_progress
end
response
@@ -90,6 +92,10 @@ class PostReceiveService
banner&.message
end
+
+ def record_onboarding_progress
+ NamespaceOnboardingAction.create_action(project.namespace, :git_write)
+ end
end
PostReceiveService.prepend_if_ee('EE::PostReceiveService')
diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb
index ab8f53a3757..014fb0e3ed3 100644
--- a/app/services/projects/alerting/notify_service.rb
+++ b/app/services/projects/alerting/notify_service.rb
@@ -2,10 +2,16 @@
module Projects
module Alerting
- class NotifyService < BaseService
+ class NotifyService
+ include BaseServiceUtility
include Gitlab::Utils::StrongMemoize
include ::IncidentManagement::Settings
+ def initialize(project, payload)
+ @project = project
+ @payload = payload
+ end
+
def execute(token, integration = nil)
@integration = integration
@@ -24,7 +30,7 @@ module Projects
private
- attr_reader :integration
+ attr_reader :project, :payload, :integration
def process_alert
if alert.persisted?
@@ -101,7 +107,7 @@ module Projects
def incoming_payload
strong_memoize(:incoming_payload) do
- Gitlab::AlertManagement::Payload.parse(project, params.to_h)
+ Gitlab::AlertManagement::Payload.parse(project, payload.to_h)
end
end
@@ -110,7 +116,7 @@ module Projects
end
def valid_payload_size?
- Gitlab::Utils::DeepSize.new(params).valid?
+ Gitlab::Utils::DeepSize.new(payload).valid?
end
def active_integration?
diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb
index 505ddaf50e3..410cf6c624e 100644
--- a/app/services/projects/container_repository/delete_tags_service.rb
+++ b/app/services/projects/container_repository/delete_tags_service.rb
@@ -36,6 +36,7 @@ module Projects
def log_response(response)
log_data = LOG_DATA_BASE.merge(
container_repository_id: @container_repository.id,
+ project_id: @container_repository.project_id,
message: 'deleted tags',
deleted_tags_count: response[:deleted]&.size
).compact
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index 1cd81fe37c7..228115d72b8 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -14,7 +14,7 @@ module Projects
groups +
project_members
- participants.uniq
+ render_participants_as_hash(participants.uniq)
end
def project_members
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 8ad4f59373d..93165a58470 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -3,7 +3,7 @@
module Projects
module Prometheus
module Alerts
- class NotifyService < BaseService
+ class NotifyService
include Gitlab::Utils::StrongMemoize
include ::IncidentManagement::Settings
@@ -17,28 +17,35 @@ module Projects
SUPPORTED_VERSION = '4'
- def execute(token, _integration = nil)
+ def initialize(project, payload)
+ @project = project
+ @payload = payload
+ end
+
+ def execute(token, integration = nil)
return bad_request unless valid_payload_size?
- return unprocessable_entity unless self.class.processable?(params)
- return unauthorized unless valid_alert_manager_token?(token)
+ return unprocessable_entity unless self.class.processable?(payload)
+ return unauthorized unless valid_alert_manager_token?(token, integration)
process_prometheus_alerts
ServiceResponse.success
end
- def self.processable?(params)
+ def self.processable?(payload)
# Workaround for https://gitlab.com/gitlab-org/gitlab/-/issues/220496
- return false unless params
+ return false unless payload
- REQUIRED_PAYLOAD_KEYS.subset?(params.keys.to_set) &&
- params['version'] == SUPPORTED_VERSION
+ REQUIRED_PAYLOAD_KEYS.subset?(payload.keys.to_set) &&
+ payload['version'] == SUPPORTED_VERSION
end
private
+ attr_reader :project, :payload
+
def valid_payload_size?
- Gitlab::Utils::DeepSize.new(params).valid?
+ Gitlab::Utils::DeepSize.new(payload).valid?
end
def firings
@@ -50,12 +57,12 @@ module Projects
end
def alerts
- params['alerts']
+ payload['alerts']
end
- def valid_alert_manager_token?(token)
+ def valid_alert_manager_token?(token, integration)
valid_for_manual?(token) ||
- valid_for_alerts_endpoint?(token) ||
+ valid_for_alerts_endpoint?(token, integration) ||
valid_for_managed?(token)
end
@@ -70,11 +77,10 @@ module Projects
end
end
- def valid_for_alerts_endpoint?(token)
- return false unless project.alerts_service_activated?
+ def valid_for_alerts_endpoint?(token, integration)
+ return false unless integration&.active?
- # Here we are enforcing the existence of the token
- compare_token(token, project.alerts_service.token)
+ compare_token(token, integration.token)
end
def valid_for_managed?(token)
@@ -122,7 +128,7 @@ module Projects
def process_prometheus_alerts
alerts.each do |alert|
AlertManagement::ProcessPrometheusAlertService
- .new(project, nil, alert.to_h)
+ .new(project, alert.to_h)
.execute
end
end
diff --git a/app/services/projects/schedule_bulk_repository_shard_moves_service.rb b/app/services/projects/schedule_bulk_repository_shard_moves_service.rb
new file mode 100644
index 00000000000..dd49910207f
--- /dev/null
+++ b/app/services/projects/schedule_bulk_repository_shard_moves_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Projects
+ # Tries to schedule a move for every project with repositories on the source shard
+ class ScheduleBulkRepositoryShardMovesService
+ include BaseServiceUtility
+
+ def execute(source_storage_name, destination_storage_name = nil)
+ shard = Shard.find_by_name!(source_storage_name)
+
+ ProjectRepository.for_shard(shard).each_batch(column: :project_id) do |relation|
+ Project.id_in(relation.select(:project_id)).each do |project|
+ project.with_lock do
+ next if project.repository_storage != source_storage_name
+
+ storage_move = project.repository_storage_moves.build(
+ source_storage_name: source_storage_name,
+ destination_storage_name: destination_storage_name
+ )
+
+ unless storage_move.schedule
+ log_info("Project #{project.full_path} (#{project.id}) was skipped: #{storage_move.errors.full_messages.to_sentence}")
+ end
+ end
+ end
+ end
+
+ success
+ end
+
+ def self.enqueue(source_storage_name, destination_storage_name = nil)
+ ::ProjectScheduleBulkRepositoryShardMovesWorker.perform_async(source_storage_name, destination_storage_name)
+ end
+ end
+end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 5178c76f0fc..1574c90d2ac 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -59,7 +59,7 @@ module Projects
raise TransferError.new(s_("TransferProject|Root namespace can't be updated if project has NPM packages"))
end
- attempt_transfer_transaction
+ proceed_to_transfer
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -67,7 +67,7 @@ module Projects
new_namespace.root_ancestor == project.namespace.root_ancestor
end
- def attempt_transfer_transaction
+ def proceed_to_transfer
Project.transaction do
project.expire_caches_before_rename(@old_path)
@@ -87,6 +87,8 @@ module Projects
# Move uploads
move_project_uploads(project)
+ update_integrations
+
project.old_path_with_namespace = @old_path
update_repository_configuration(@new_path)
@@ -214,6 +216,11 @@ module Projects
project.shared_runners_enabled = false
end
end
+
+ def update_integrations
+ project.services.inherit.delete_all
+ Service.create_from_active_default_integrations(project, :project_id)
+ end
end
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index b9c579a130f..53872c67f49 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -4,6 +4,9 @@ module Projects
class UpdatePagesService < BaseService
InvalidStateError = Class.new(StandardError)
FailedToExtractError = Class.new(StandardError)
+ ExclusiveLeaseTaken = Class.new(StandardError)
+
+ include ::Pages::LegacyStorageLease
BLOCK_SIZE = 32.kilobytes
PUBLIC_DIR = 'public'
@@ -109,6 +112,17 @@ module Projects
end
def deploy_page!(archive_public_path)
+ deployed = try_obtain_lease do
+ deploy_page_unsafe!(archive_public_path)
+ true
+ end
+
+ unless deployed
+ raise ExclusiveLeaseTaken, "Failed to deploy pages - other deployment is in progress"
+ end
+ end
+
+ def deploy_page_unsafe!(archive_public_path)
# Do atomic move of pages
# Move and removal may not be atomic, but they are significantly faster then extracting and removal
# 1. We move deployed public to previous public path (file removal is slow)
@@ -125,8 +139,6 @@ module Projects
end
def create_pages_deployment(artifacts_path, build)
- return unless Feature.enabled?(:zip_pages_deployments, project, default_enabled: true)
-
# we're using the full archive and pages daemon needs to read it
# so we want the total count from entries, not only "public/" directory
# because it better approximates work we need to do before we can serve the site
diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb
index 38ef80ced56..d0e1577bd8d 100644
--- a/app/services/releases/base_service.rb
+++ b/app/services/releases/base_service.rb
@@ -11,8 +11,6 @@ module Releases
@project, @current_user, @params = project, user, params.dup
end
- delegate :repository, to: :project
-
def tag_name
params[:tag]
end
@@ -39,22 +37,18 @@ module Releases
end
end
- def existing_tag
- strong_memoize(:existing_tag) do
- repository.find_tag(tag_name)
- end
- end
-
- def tag_exist?
- existing_tag.present?
- end
-
def repository
strong_memoize(:repository) do
project.repository
end
end
+ def existing_tag
+ strong_memoize(:existing_tag) do
+ repository.find_tag(tag_name)
+ end
+ end
+
def milestones
return [] unless param_for_milestone_titles_provided?
@@ -78,7 +72,7 @@ module Releases
end
def param_for_milestone_titles_provided?
- params.key?(:milestones)
+ !!params[:milestones]
end
def execute_hooks(release, action = 'create')
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index deefe559d5d..11fdbaf3169 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -10,7 +10,7 @@ module Releases
# should be found before the creation of new tag
# because tag creation can spawn new pipeline
# which won't have any data for evidence yet
- evidence_pipeline = find_evidence_pipeline
+ evidence_pipeline = Releases::EvidencePipelineFinder.new(project, params).execute
tag = ensure_tag
@@ -78,26 +78,10 @@ module Releases
)
end
- def find_evidence_pipeline
- # TODO: remove this with the release creation moved to it's own form https://gitlab.com/gitlab-org/gitlab/-/issues/214245
- return params[:evidence_pipeline] if params[:evidence_pipeline]
-
- sha = existing_tag&.dereferenced_target&.sha
- sha ||= repository.commit(ref)&.sha
-
- return unless sha
-
- project.ci_pipelines.for_sha(sha).last
- end
-
def create_evidence!(release, pipeline)
- return if release.historical_release?
+ return if release.historical_release? || release.upcoming_release?
- if release.upcoming_release?
- CreateEvidenceWorker.perform_at(release.released_at, release.id, pipeline&.id)
- else
- CreateEvidenceWorker.perform_async(release.id, pipeline&.id)
- end
+ ::Releases::CreateEvidenceWorker.perform_async(release.id, pipeline&.id)
end
end
end
diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb
index dc23f727079..ddf3b05ac10 100644
--- a/app/services/resource_events/change_labels_service.rb
+++ b/app/services/resource_events/change_labels_service.rb
@@ -24,6 +24,8 @@ module ResourceEvents
Gitlab::Database.bulk_insert(ResourceLabelEvent.table_name, labels) # rubocop:disable Gitlab/BulkInsert
resource.expire_note_etag_cache
+
+ Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_label_changed_action(author: user) if resource.is_a?(Issue)
end
private
diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb
index c837b75f439..32d1c5c1c87 100644
--- a/app/services/service_desk_settings/update_service.rb
+++ b/app/services/service_desk_settings/update_service.rb
@@ -5,7 +5,7 @@ module ServiceDeskSettings
def execute
settings = ServiceDeskSetting.safe_find_or_create_by!(project_id: project.id)
- unless ::Feature.enabled?(:service_desk_custom_address, project)
+ unless ::Feature.enabled?(:service_desk_custom_address, project, default_enabled: true)
params.delete(:project_key)
end
diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb
index 2fbeaf4405c..8ab1193b04f 100644
--- a/app/services/submit_usage_ping_service.rb
+++ b/app/services/submit_usage_ping_service.rb
@@ -43,8 +43,6 @@ class SubmitUsagePingService
private
def save_raw_usage_data(usage_data)
- return unless Feature.enabled?(:save_raw_usage_data)
-
RawUsageData.safe_find_or_create_by(recorded_at: usage_data[:recorded_at]) do |record|
record.payload = usage_data
end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index 0d369c23b57..881a139437a 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class SystemHooksService
+ BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember].freeze
+
def execute_hooks_for(model, event)
data = build_event_data(model, event)
@@ -20,6 +22,9 @@ class SystemHooksService
private
def build_event_data(model, event)
+ # return entire event data from its builder class, if available.
+ return builder_driven_event_data(model, event) if builder_driven_event_data_available?(model)
+
data = {
event_name: build_event_name(model, event),
created_at: model.created_at&.xmlschema,
@@ -62,8 +67,6 @@ class SystemHooksService
old_full_path: model.full_path_before_last_save
)
end
- when GroupMember
- data.merge!(group_member_data(model))
end
data
@@ -75,10 +78,6 @@ class SystemHooksService
return "user_add_to_team" if event == :create
return "user_remove_from_team" if event == :destroy
return "user_update_for_team" if event == :update
- when GroupMember
- return 'user_add_to_group' if event == :create
- return 'user_remove_from_group' if event == :destroy
- return 'user_update_for_group' if event == :update
else
"#{model.class.name.downcase}_#{event}"
end
@@ -128,19 +127,6 @@ class SystemHooksService
}
end
- def group_member_data(model)
- {
- group_name: model.group.name,
- group_path: model.group.path,
- group_id: model.group.id,
- user_username: model.user.username,
- user_name: model.user.name,
- user_email: model.user.email,
- user_id: model.user.id,
- group_access: model.human_access
- }
- end
-
def user_data(model)
{
name: model.name,
@@ -149,6 +135,17 @@ class SystemHooksService
username: model.username
}
end
+
+ def builder_driven_event_data_available?(model)
+ model.class.in?(BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES)
+ end
+
+ def builder_driven_event_data(model, event)
+ case model
+ when GroupMember
+ Gitlab::HookData::GroupMemberBuilder.new(model).build(event)
+ end
+ 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 eacc88f98a3..58f72e9badc 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -226,6 +226,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction)
end
+ def noteable_cloned(noteable, project, noteable_ref, author, direction:)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction)
+ end
+
def mark_duplicate_issue(noteable, project, author, canonical_issue)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_duplicate_issue(canonical_issue)
end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 7a73af0a81a..b344b240a07 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -242,6 +242,29 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
+ # Called when noteable has been cloned
+ #
+ # noteable_ref - Referenced noteable
+ # direction - symbol, :to or :from
+ #
+ # Example Note text:
+ #
+ # "cloned to some_namespace/project_new#11"
+ #
+ # Returns the created Note object
+ def noteable_cloned(noteable_ref, direction)
+ unless [:to, :from].include?(direction)
+ raise ArgumentError, "Invalid direction `#{direction}`"
+ end
+
+ cross_reference = noteable_ref.to_reference(project)
+ body = "cloned #{direction} #{cross_reference}"
+
+ issue_activity_counter.track_issue_cloned_action(author: author) if noteable.is_a?(Issue) && direction == :to
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned'))
+ end
+
# Called when the confidentiality changes
#
# Example Note text:
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
index 403944557a2..ba6ead41836 100644
--- a/app/services/upload_service.rb
+++ b/app/services/upload_service.rb
@@ -6,16 +6,18 @@ class UploadService
end
def execute
- return unless @file && @file.size <= max_attachment_size
+ return unless file && file.size <= max_attachment_size
- uploader = @uploader_class.new(@model, nil, @uploader_context)
- uploader.store!(@file)
+ uploader = uploader_class.new(model, nil, **uploader_context)
+ uploader.store!(file)
uploader
end
private
+ attr_reader :model, :file, :uploader_class, :uploader_context
+
def max_attachment_size
Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
end
diff --git a/app/services/users/approve_service.rb b/app/services/users/approve_service.rb
index 27668e9430e..debd1e8cd17 100644
--- a/app/services/users/approve_service.rb
+++ b/app/services/users/approve_service.rb
@@ -7,8 +7,9 @@ module Users
end
def execute(user)
- return error(_('You are not allowed to approve a user')) unless allowed?
- return error(_('The user you are trying to approve is not pending an approval')) unless approval_required?(user)
+ return error(_('You are not allowed to approve a user'), :forbidden) unless allowed?
+ return error(_('The user you are trying to approve is not pending an approval'), :conflict) if user.active?
+ return error(_('The user you are trying to approve is not pending an approval'), :conflict) unless approval_required?(user)
if user.activate
# Resends confirmation email if the user isn't confirmed yet.
@@ -18,9 +19,9 @@ module Users
DeviseMailer.user_admin_approval(user).deliver_later
after_approve_hook(user)
- success
+ success(message: 'Success', http_status: :created)
else
- error(user.errors.full_messages.uniq.join('. '))
+ error(user.errors.full_messages.uniq.join('. '), :unprocessable_entity)
end
end
diff --git a/app/services/users/reject_service.rb b/app/services/users/reject_service.rb
new file mode 100644
index 00000000000..dd72547c688
--- /dev/null
+++ b/app/services/users/reject_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Users
+ class RejectService < BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(user)
+ return error(_('You are not allowed to reject a user')) unless allowed?
+ return error(_('This user does not have a pending request')) unless user.blocked_pending_approval?
+
+ user.delete_async(deleted_by: current_user, params: { hard_delete: true })
+
+ NotificationService.new.user_admin_rejection(user.name, user.email)
+
+ success
+ end
+
+ private
+
+ attr_reader :current_user
+
+ def allowed?
+ can?(current_user, :reject_user)
+ end
+ end
+end
diff --git a/app/services/users/set_status_service.rb b/app/services/users/set_status_service.rb
index 356c8782af1..a907937070f 100644
--- a/app/services/users/set_status_service.rb
+++ b/app/services/users/set_status_service.rb
@@ -14,10 +14,10 @@ module Users
def execute
return false unless can?(current_user, :update_user_status, target_user)
- if params[:emoji].present? || params[:message].present? || params[:availability].present?
- set_status
- else
+ if status_cleared?
remove_status
+ else
+ set_status
end
end
@@ -25,8 +25,7 @@ module Users
def set_status
params[:emoji] = UserStatus::DEFAULT_EMOJI if params[:emoji].blank?
- params.delete(:availability) if params[:availability].blank?
- return false if params[:availability].present? && UserStatus.availabilities.keys.exclude?(params[:availability])
+ params[:availability] = UserStatus.availabilities[:not_set] unless new_user_availability
user_status.update(params)
end
@@ -38,5 +37,15 @@ module Users
def user_status
target_user.status || target_user.build_status
end
+
+ def status_cleared?
+ params[:emoji].blank? &&
+ params[:message].blank? &&
+ (new_user_availability.blank? || new_user_availability == UserStatus.availabilities[:not_set])
+ end
+
+ def new_user_availability
+ UserStatus.availabilities[params[:availability]]
+ end
end
end
diff --git a/app/services/users/validate_otp_service.rb b/app/services/users/validate_otp_service.rb
index a9ce7959aea..c8a9f217d22 100644
--- a/app/services/users/validate_otp_service.rb
+++ b/app/services/users/validate_otp_service.rb
@@ -2,10 +2,14 @@
module Users
class ValidateOtpService < BaseService
+ include ::Gitlab::Auth::Otp::Fortinet
+
def initialize(current_user)
@current_user = current_user
- @strategy = if Feature.enabled?(:forti_authenticator, current_user)
+ @strategy = if forti_authenticator_enabled?(current_user)
::Gitlab::Auth::Otp::Strategies::FortiAuthenticator.new(current_user)
+ elsif forti_token_cloud_enabled?(current_user)
+ ::Gitlab::Auth::Otp::Strategies::FortiTokenCloud.new(current_user)
else
::Gitlab::Auth::Otp::Strategies::Devise.new(current_user)
end
diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb
index 2306313fc82..d80725cb051 100644
--- a/app/uploaders/terraform/state_uploader.rb
+++ b/app/uploaders/terraform/state_uploader.rb
@@ -6,17 +6,33 @@ module Terraform
storage_options Gitlab.config.terraform_state
- delegate :project_id, to: :model
+ delegate :terraform_state, :project_id, to: :model
# Use Lockbox to encrypt/decrypt the stored file (registers CarrierWave callbacks)
encrypt(key: :key)
def filename
- "#{model.uuid}.tfstate"
+ # This check is required to maintain backwards compatibility with
+ # states that were created prior to versioning being supported.
+ # This can be removed in 14.0 when support for these states is dropped.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960
+ if terraform_state.versioning_enabled?
+ "#{model.version}.tfstate"
+ else
+ "#{model.uuid}.tfstate"
+ end
end
def store_dir
- project_id.to_s
+ # This check is required to maintain backwards compatibility with
+ # states that were created prior to versioning being supported.
+ # This can be removed in 14.0 when support for these states is dropped.
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/258960
+ if terraform_state.versioning_enabled?
+ Gitlab::HashedPath.new(model.uuid, root_hash: project_id)
+ else
+ project_id.to_s
+ end
end
def key
diff --git a/app/uploaders/terraform/versioned_state_uploader.rb b/app/uploaders/terraform/versioned_state_uploader.rb
deleted file mode 100644
index e50ab6c7dc6..00000000000
--- a/app/uploaders/terraform/versioned_state_uploader.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Terraform
- class VersionedStateUploader < StateUploader
- delegate :terraform_state, to: :model
-
- def filename
- if terraform_state.versioning_enabled?
- "#{model.version}.tfstate"
- else
- "#{model.uuid}.tfstate"
- end
- end
-
- def store_dir
- if terraform_state.versioning_enabled?
- Gitlab::HashedPath.new(model.uuid, root_hash: project_id)
- else
- project_id.to_s
- end
- end
- end
-end
diff --git a/app/validators/json_schema_validator.rb b/app/validators/json_schema_validator.rb
index f8c1727035c..fee4a00cec5 100644
--- a/app/validators/json_schema_validator.rb
+++ b/app/validators/json_schema_validator.rb
@@ -12,6 +12,7 @@
class JsonSchemaValidator < ActiveModel::EachValidator
FILENAME_ALLOWED = /\A[a-z0-9_-]*\Z/.freeze
FilenameError = Class.new(StandardError)
+ JSON_VALIDATOR_MAX_DRAFT_VERSION = 4
def initialize(options)
raise ArgumentError, "Expected 'filename' as an argument" unless options[:filename]
@@ -29,10 +30,18 @@ class JsonSchemaValidator < ActiveModel::EachValidator
private
def valid_schema?(value)
- JSON::Validator.validate(schema_path, 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
end
def schema_path
Rails.root.join('app', 'validators', 'json_schemas', "#{options[:filename]}.json").to_s
end
+
+ def draft_version
+ options[:draft] || JSON_VALIDATOR_MAX_DRAFT_VERSION
+ end
end
diff --git a/app/validators/json_schemas/codeclimate.json b/app/validators/json_schemas/codeclimate.json
new file mode 100644
index 00000000000..56056c62c4e
--- /dev/null
+++ b/app/validators/json_schemas/codeclimate.json
@@ -0,0 +1,34 @@
+{
+ "description": "Codequality used by codeclimate parser",
+ "type": "object",
+ "required": ["description", "fingerprint", "severity", "location"],
+ "properties": {
+ "description": { "type": "string" },
+ "fingerprint": { "type": "string" },
+ "severity": { "type": "string" },
+ "location": {
+ "type": "object",
+ "properties": {
+ "path": { "type": "string" },
+ "lines": {
+ "type": "object",
+ "properties": {
+ "begin": { "type": "integer" }
+ }
+ },
+ "positions": {
+ "type": "object",
+ "properties": {
+ "begin": {
+ "type": "object",
+ "properties": {
+ "line": { "type": "integer" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "additionalProperties": true
+}
diff --git a/app/validators/json_schemas/http_integration_payload_attribute_mapping.json b/app/validators/json_schemas/http_integration_payload_attribute_mapping.json
new file mode 100644
index 00000000000..e457b8a292b
--- /dev/null
+++ b/app/validators/json_schemas/http_integration_payload_attribute_mapping.json
@@ -0,0 +1,14 @@
+{
+ "type": "object",
+ "patternProperties": {
+ ".*": {
+ "type": "object",
+ "required": ["path", "type"],
+ "properties": {
+ "path": { "type": "array" },
+ "type": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
+ }
+}
diff --git a/app/validators/json_schemas/vulnerability_finding_details.json b/app/validators/json_schemas/vulnerability_finding_details.json
new file mode 100644
index 00000000000..f2940866f4b
--- /dev/null
+++ b/app/validators/json_schemas/vulnerability_finding_details.json
@@ -0,0 +1,182 @@
+{
+ "type": "object",
+ "description": "The schema for vulnerability finding details",
+ "additionalProperties": false,
+ "patternProperties": {
+ "^.*$": {
+ "allOf": [
+ { "$ref": "#/definitions/named_field" },
+ { "$ref": "#/definitions/type_list" }
+ ]
+ }
+ },
+ "definitions": {
+ "type_list": {
+ "oneOf": [
+ { "$ref": "#/definitions/named_list" },
+ { "$ref": "#/definitions/list" },
+ { "$ref": "#/definitions/table" },
+
+ { "$ref": "#/definitions/text" },
+ { "$ref": "#/definitions/url" },
+ { "$ref": "#/definitions/code" },
+ { "$ref": "#/definitions/int" },
+
+ { "$ref": "#/definitions/commit" },
+ { "$ref": "#/definitions/file_location" },
+ { "$ref": "#/definitions/module_location" }
+ ]
+ },
+ "lang_text": {
+ "type": "object",
+ "required": [ "value", "lang" ],
+ "properties": {
+ "lang": { "type": "string" },
+ "value": { "type": "string" }
+ }
+ },
+ "lang_text_list": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/lang_text" }
+ },
+ "named_field": {
+ "type": "object",
+ "required": [ "name" ],
+ "properties": {
+ "name": { "$ref": "#/definitions/lang_text_list" },
+ "description": { "$ref": "#/definitions/lang_text_list" }
+ }
+ },
+ "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/type_list" }
+ ]
+ }
+ }
+ }
+ }
+ },
+ "list": {
+ "type": "object",
+ "description": "A list of typed fields",
+ "required": [ "type", "items" ],
+ "properties": {
+ "type": { "const": "list" },
+ "items": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/type_list" }
+ }
+ }
+ },
+ "table": {
+ "type": "object",
+ "description": "A table of typed fields",
+ "required": [],
+ "properties": {
+ "type": { "const": "table" },
+ "items": {
+ "type": "object",
+ "properties": {
+ "header": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/type_list"
+ }
+ },
+ "rows": {
+ "type": "array",
+ "items": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/type_list"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "text": {
+ "type": "object",
+ "description": "Raw text",
+ "required": [ "type", "value" ],
+ "properties": {
+ "type": { "const": "text" },
+ "value": { "$ref": "#/definitions/lang_text_list" }
+ }
+ },
+ "url": {
+ "type": "object",
+ "description": "A single URL",
+ "required": [ "type", "href" ],
+ "properties": {
+ "type": { "const": "url" },
+ "text": { "$ref": "#/definitions/lang_text_list" },
+ "href": { "type": "string" }
+ }
+ },
+ "code": {
+ "type": "object",
+ "description": "A codeblock",
+ "required": [ "type", "value" ],
+ "properties": {
+ "type": { "const": "code" },
+ "value": { "type": "string" },
+ "lang": { "type": "string" }
+ }
+ },
+ "int": {
+ "type": "object",
+ "description": "An integer",
+ "required": [ "type", "value" ],
+ "properties": {
+ "type": { "const": "int" },
+ "value": { "type": "integer" },
+ "format": {
+ "type": "string",
+ "enum": [ "default", "hex" ]
+ }
+ }
+ },
+ "commit": {
+ "type": "object",
+ "description": "A specific commit within the project",
+ "required": [ "type", "value" ],
+ "properties": {
+ "type": { "const": "commit" },
+ "value": { "type": "string", "description": "The commit SHA" }
+ }
+ },
+ "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" },
+ "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" },
+ "offset": { "type": "integer" }
+ }
+ }
+ }
+}
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index ad3795445d1..67ac9d1c7b8 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -19,7 +19,7 @@
= link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm"
%hr
= f.hidden_field :header_logo_cache
- = f.file_field :header_logo, class: ""
+ = f.file_field :header_logo, class: "", accept: 'image/*'
.hint
Maximum file size is 1MB. Pages are optimized for a 28px tall header logo
%hr
@@ -38,7 +38,7 @@
= link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm"
%hr
= f.hidden_field :favicon_cache
- = f.file_field :favicon, class: ''
+ = f.file_field :favicon, class: '', accept: 'image/*'
.hint
Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}.
%br
@@ -70,7 +70,7 @@
= link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn gl-button btn-danger btn-danger-secondary btn-sm remove-logo"
%hr
= f.hidden_field :logo_cache
- = f.file_field :logo, class: ""
+ = f.file_field :logo, class: "", accept: 'image/*'
.hint
Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.
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 f46eb84ce8e..46155f3f670 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -52,6 +52,9 @@
= link_to _('More information'), help_page_path('user/permissions', anchor: 'setting-new-users-to-external'),
target: '_blank'
.form-group
+ = f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light'
+ = f.text_field :personal_access_token_prefix, placeholder: _('Max 20 characters'), class: 'form-control'
+ .form-group
= f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold'
.form-check
= f.check_box :user_show_add_ssh_key_message, class: 'form-check-input'
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index 5c0e544eaad..589d754be04 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -24,8 +24,13 @@
.form-group
= f.label :eks_access_key_id, 'Access key ID', class: 'label-bold'
= f.text_field :eks_access_key_id, class: 'form-control'
+ .form-text.text-muted
+ = _('AWS Access Key. Only required if not using role instance credentials')
+
.form-group
= f.label :eks_secret_access_key, 'Secret access key', class: 'label-bold'
= f.password_field :eks_secret_access_key, autocomplete: 'off', class: 'form-control'
+ .form-text.text-muted
+ = _('AWS Secret Access Key. Only required if not using role instance credentials')
= f.submit 'Save changes', class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml
index c1565cf42e1..b06070d15d4 100644
--- a/app/views/admin/application_settings/_ip_limits.html.haml
+++ b/app/views/admin/application_settings/_ip_limits.html.haml
@@ -2,44 +2,52 @@
= form_errors(@application_setting)
%fieldset
+ %h5
+ = _('Unauthenticated request rate limit')
.form-group
.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' do
+ = f.label :throttle_unauthenticated_enabled, class: 'form-check-label label-bold' do
Enable unauthenticated request rate limit
%span.form-text.text-muted
Helps reduce request volume (e.g. from crawlers or abusive bots)
.form-group
- = f.label :throttle_unauthenticated_requests_per_period, 'Max 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'
.form-group
- = f.label :throttle_unauthenticated_period_in_seconds, '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'
+ %hr
+ %h5
+ = _('Authenticated API request rate limit')
.form-group
.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' do
+ = f.label :throttle_authenticated_api_enabled, class: 'form-check-label label-bold' do
Enable authenticated API request rate limit
%span.form-text.text-muted
Helps reduce request volume (e.g. from crawlers or abusive bots)
.form-group
- = f.label :throttle_authenticated_api_requests_per_period, 'Max 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'
.form-group
- = f.label :throttle_authenticated_api_period_in_seconds, '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'
+ %hr
+ %h5
+ = _('Authenticated web request rate limit')
.form-group
.form-check
= f.check_box :throttle_authenticated_web_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_web_checkbox' }
- = f.label :throttle_authenticated_web_enabled, class: 'form-check-label' do
+ = f.label :throttle_authenticated_web_enabled, class: 'form-check-label label-bold' do
Enable authenticated web request rate limit
%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 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'
.form-group
- = f.label :throttle_authenticated_web_period_in_seconds, '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'
= f.submit 'Save changes', class: "gl-button btn btn-success", 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
new file mode 100644
index 00000000000..1547b28c651
--- /dev/null
+++ b/app/views/admin/application_settings/_kroki.html.haml
@@ -0,0 +1,25 @@
+- expanded = integration_expanded?('kroki_')
+%section.settings.as-kroki.no-animate#js-kroki-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Kroki')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %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_errors(@application_setting) if expanded
+
+ %fieldset
+ .form-group
+ .form-check
+ = f.check_box :kroki_enabled, class: 'form-check-input'
+ = f.label :kroki_enabled, _('Enable Kroki'), class: 'form-check-label'
+ .form-group
+ = f.label :kroki_url, 'Kroki URL', class: 'label-bold'
+ = f.text_field :kroki_url, class: 'form-control', placeholder: 'http://your-kroki-instance:8000'
+ .form-text.text-muted
+ = (_('When Kroki is enabled, GitLab sends diagrams to an instance of Kroki to display them as images. You can use the free public cloud instance %{kroki_public_url} or you can %{install_link} on your own infrastructure. Once you\'ve installed Kroki, make sure to update the server URL to point to your instance.') % { kroki_public_url: '<code>https://kroki.io</code>', install_link: link_to('install Kroki', 'https://docs.kroki.io/kroki/setup/install/', target: '_blank') }).html_safe
+
+ = f.submit _('Save changes'), class: "btn gl-button btn-success"
diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml
index 30acb773424..77a310c73a8 100644
--- a/app/views/admin/application_settings/_plantuml.html.haml
+++ b/app/views/admin/application_settings/_plantuml.html.haml
@@ -18,7 +18,7 @@
= f.label :plantuml_enabled, _('Enable PlantUML'), class: 'form-check-label'
.form-group
= f.label :plantuml_url, 'PlantUML URL', class: 'label-bold'
- = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080'
+ = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://your-plantuml-instance:8080'
.form-text.text-muted
Allow rendering of
= link_to "PlantUML", "http://plantuml.com"
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index c3deb8af99e..2f2d42e297e 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -11,7 +11,7 @@
= _("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'
+ = 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
@@ -77,4 +77,4 @@
= f.label :after_sign_up_text, class: 'label-bold'
= f.text_area :after_sign_up_text, class: 'form-control', rows: 4
.form-text.text-muted Markdown enabled
- = f.submit 'Save changes', class: "gl-button btn btn-success"
+ = f.submit 'Save changes', class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
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 46d8a8ac9c7..709ce497132 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -66,4 +66,12 @@
.form-group
= f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold'
= f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
+
+ .form-group
+ %label.label-bold= s_('AdminSettings|Feed token')
+ .form-check
+ = f.check_box :disable_feed_token, class: 'form-check-input'
+ = f.label :disable_feed_token, class: 'form-check-label' do
+ = s_('AdminSettings|Disable feed token')
+
= f.submit _('Save changes'), class: "gl-button btn btn-success"
diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml
index 5c3f68843a2..8f15dcac40a 100644
--- a/app/views/admin/application_settings/general.html.haml
+++ b/app/views/admin/application_settings/general.html.haml
@@ -35,7 +35,7 @@
.settings-content
= render 'diff_limits'
-%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?) }
+%section.settings.as-signup.no-animate#js-signup-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sign_up_restrictions_settings_content' } }
.settings-header
%h4
= _('Sign-up restrictions')
@@ -103,20 +103,10 @@
= s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.')
= f.submit _('Save changes'), class: "gl-button btn btn-success"
-- if Feature.enabled?(:maintenance_mode)
- %section.settings.no-animate#js-maintenance-mode-toggle{ class: ('expanded' if expanded_by_default?) }
- .settings-header
- %h4
- = _('Maintenance mode')
- %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
- = expanded_by_default? ? _('Collapse') : _('Expand')
- %p
- = _('Prevent users from performing write operations on GitLab while performing maintenance.')
- .settings-content
- #js-maintenance-mode-settings
-
+= render_if_exists 'admin/application_settings/maintenance_mode_settings_form'
= render_if_exists 'admin/application_settings/elasticsearch_form'
= render 'admin/application_settings/gitpod'
+= render 'admin/application_settings/kroki'
= render 'admin/application_settings/plantuml'
= render 'admin/application_settings/sourcegraph'
= render_if_exists 'admin/application_settings/slack'
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index 9f1b7195ab7..4959e596148 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -6,7 +6,7 @@
.settings-header
%h4
= _('Metrics - Prometheus')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Enable and configure Prometheus metrics.')
@@ -17,7 +17,7 @@
.settings-header
%h4
= _('Metrics - Grafana')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Enable and configure Grafana.')
@@ -28,7 +28,7 @@
.settings-header
%h4
= _('Profiling - Performance bar')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Enable access to the Performance Bar for a given group.')
@@ -42,7 +42,7 @@
.settings-header#usage-statistics
%h4
= _('Usage statistics')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
= _('Enable or disable version check and usage ping.')
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 220a211cca6..8cc04392752 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -9,7 +9,7 @@
dismissible: true.to_s } }
= notice[:message].html_safe
-- if @license.present? && show_license_breakdown?
+- if @license.present?
.license-panel.gl-mt-5
= render_if_exists 'admin/licenses/summary'
= render_if_exists 'admin/licenses/breakdown'
diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml
index dc3bda3a994..75398f3aa21 100644
--- a/app/views/admin/dev_ops_report/show.html.haml
+++ b/app/views/admin/dev_ops_report/show.html.haml
@@ -3,7 +3,7 @@
.container
.gl-mt-3
- - if Gitlab.ee? && Feature.enabled?(:devops_adoption_feature) && License.feature_available?(:devops_adoption)
+ - if Gitlab.ee? && Feature.enabled?(:devops_adoption_feature, default_enabled: true) && License.feature_available?(:devops_adoption)
= render_if_exists 'admin/dev_ops_report/devops_tabs'
- else
= render 'report'
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 6174da14ac0..e4517dca6d0 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -1,6 +1,7 @@
= form_for [:admin, @group] do |f|
= form_errors(@group)
= render 'shared/group_form', f: f
+ = render 'shared/group_form_description', 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/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml
index 17bb054b869..5bc5404fada 100644
--- a/app/views/admin/hooks/_form.html.haml
+++ b/app/views/admin/hooks/_form.html.haml
@@ -18,28 +18,28 @@
.gl-mt-3
= form.check_box :repository_update_events, class: 'float-left'
- .prepend-left-20
+ .gl-ml-6
= form.label :repository_update_events, class: 'list-label' do
%strong Repository update events
%p.light
This URL will be triggered when repository is updated
%li
= form.check_box :push_events, class: 'float-left'
- .prepend-left-20
+ .gl-ml-6
= form.label :push_events, class: 'list-label' do
%strong Push events
%p.light
This URL will be triggered for each branch updated to the repository
%li
= form.check_box :tag_push_events, class: 'float-left'
- .prepend-left-20
+ .gl-ml-6
= form.label :tag_push_events, class: 'list-label' do
%strong Tag push events
%p.light
This URL will be triggered when a new tag is pushed to the repository
%li
= form.check_box :merge_requests_events, class: 'float-left'
- .prepend-left-20
+ .gl-ml-6
= form.label :merge_requests_events, class: 'list-label' do
%strong Merge request events
%p.light
diff --git a/app/views/admin/labels/index.html.haml b/app/views/admin/labels/index.html.haml
index 76d37626fff..f204e620e9d 100644
--- a/app/views/admin/labels/index.html.haml
+++ b/app/views/admin/labels/index.html.haml
@@ -1,7 +1,7 @@
- page_title _("Labels")
%div
- = link_to new_admin_label_path, class: "float-right btn gl-button btn-nr btn-success" do
+ = link_to new_admin_label_path, class: "float-right btn gl-button btn-success" do
= _('New label')
%h3.page-title
= _('Labels')
diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml
index 3b3de042511..c6627ae0f27 100644
--- a/app/views/admin/runners/_sort_dropdown.html.haml
+++ b/app/views/admin/runners/_sort_dropdown.html.haml
@@ -3,7 +3,7 @@
.dropdown.inline.gl-ml-3
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
= sorted_by
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sorted_by)
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 2c4befb1be2..06925964dc5 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -9,7 +9,7 @@
%span.runner-state.runner-state-specific
Specific
-- page_title _("Runners")
+- page_title @runner.short_sha
- add_to_breadcrumbs _("Runners"), admin_runners_path
- breadcrumb_title "##{@runner.id}"
@@ -39,17 +39,18 @@
%thead
%tr
%th Assigned projects
- %th
- @runner.runner_projects.each do |runner_project|
- project = runner_project.project
- if project
- %tr.alert-info
+ %tr
%td
- %strong
- = project.full_name
- %td
- .float-right
- = link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'gl-button btn btn-danger btn-sm'
+ .gl-alert.gl-alert-danger
+ = sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ %strong
+ = project.full_name
+ .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
%thead
diff --git a/app/views/admin/users/_approve_user.html.haml b/app/views/admin/users/_approve_user.html.haml
index b4d960d909c..f61c9fa4b80 100644
--- a/app/views/admin/users/_approve_user.html.haml
+++ b/app/views/admin/users/_approve_user.html.haml
@@ -4,4 +4,4 @@
.card-body
= render partial: 'admin/users/user_approve_effects'
%br
- = link_to s_('AdminUsers|Approve user'), approve_admin_user_path(user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?') }
+ = link_to s_('AdminUsers|Approve user'), approve_admin_user_path(user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?'), qa_selector: 'approve_user_button' }
diff --git a/app/views/admin/users/_modals.html.haml b/app/views/admin/users/_modals.html.haml
index e56bbd06575..f6e7cefafe7 100644
--- a/app/views/admin/users/_modals.html.haml
+++ b/app/views/admin/users/_modals.html.haml
@@ -1,10 +1,5 @@
-#user-modal
-#modal-texts.hidden{ "hidden": true, "aria-hidden": true }
- %div{ data: { modal: "deactivate",
- title: s_("AdminUsers|Deactivate User %{username}?"),
- action: s_("AdminUsers|Deactivate") } }
- = render partial: 'admin/users/user_deactivation_effects'
-
+#js-delete-user-modal
+#js-modal-texts.hidden{ "hidden": true, "aria-hidden": true }
%div{ data: { modal: "delete",
title: s_("AdminUsers|Delete User %{username}?"),
action: s_('AdminUsers|Delete user'),
diff --git a/app/views/admin/users/_reject_pending_user.html.haml b/app/views/admin/users/_reject_pending_user.html.haml
new file mode 100644
index 00000000000..17108427330
--- /dev/null
+++ b/app/views/admin/users/_reject_pending_user.html.haml
@@ -0,0 +1,7 @@
+.card.border-danger
+ .card-header.bg-danger.gl-text-white
+ = s_('AdminUsers|This user has requested access')
+ .card-body
+ = render partial: 'admin/users/user_reject_effects'
+ %br
+ = link_to s_('AdminUsers|Reject request'), reject_admin_user_path(user), method: :delete, class: "btn gl-button btn-danger", data: { confirm: s_('AdminUsers|Are you sure?') }
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index 679c4805280..31fd3aea94d 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -37,26 +37,25 @@
- elsif user.blocked?
- if user.blocked_pending_approval?
= link_to s_('AdminUsers|Approve'), approve_admin_user_path(user), method: :put
- %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_block_data(user, user_block_effects) }
- = s_('AdminUsers|Block')
+ = link_to s_('AdminUsers|Reject'), reject_admin_user_path(user), method: :delete
- else
- = link_to _('Unblock'), unblock_admin_user_path(user), method: :put
+ %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) }
= s_('AdminUsers|Block')
- if user.can_be_deactivated?
%li
- %button.btn.btn-default-tertiary{ data: { 'gl-modal-action': 'deactivate',
- url: deactivate_admin_user_path(user),
- username: sanitize_name(user.name) } }
+ %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_deactivation_data(user, user_deactivation_effects) }
= s_('AdminUsers|Deactivate')
- elsif user.deactivated?
%li
- = link_to _('Activate'), activate_admin_user_path(user), method: :put
+ %button.btn.btn-default-tertiary.js-confirm-modal-button{ data: user_activation_data(user) }
+ = s_('AdminUsers|Activate')
- if user.access_locked?
%li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
- - if can?(current_user, :destroy_user, user)
+ - if can?(current_user, :destroy_user, user) && !user.blocked_pending_approval?
%li.divider
- if user.can_be_removed?
%li
diff --git a/app/views/admin/users/_user_deactivation_effects.html.haml b/app/views/admin/users/_user_deactivation_effects.html.haml
deleted file mode 100644
index dc3896e18c0..00000000000
--- a/app/views/admin/users/_user_deactivation_effects.html.haml
+++ /dev/null
@@ -1,18 +0,0 @@
-%p
- = s_('AdminUsers|Deactivating a user has the following effects:')
-%ul
- %li
- = s_('AdminUsers|The user will be logged out')
- %li
- = s_('AdminUsers|The user will not be able to access git repositories')
- %li
- = s_('AdminUsers|The user will not be able to access the API')
- %li
- = s_('AdminUsers|The user will not receive any notifications')
- %li
- = s_('AdminUsers|The user will not be able to use slash commands')
- %li
- = s_('AdminUsers|When the user logs back in, their account will reactivate as a fully active account')
- %li
- = s_('AdminUsers|Personal projects, group and user history will be left intact')
- = render_if_exists 'admin/users/user_deactivation_effects_on_seats'
diff --git a/app/views/admin/users/_user_reject_effects.html.haml b/app/views/admin/users/_user_reject_effects.html.haml
new file mode 100644
index 00000000000..17b6862b0cc
--- /dev/null
+++ b/app/views/admin/users/_user_reject_effects.html.haml
@@ -0,0 +1,10 @@
+%p
+ = s_('AdminUsers|Rejected users:')
+%ul
+ %li
+ = s_('AdminUsers|Cannot sign in or access instance information')
+ %li
+ = s_('AdminUsers|Will be deleted')
+%p
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path("user/profile/account/delete_account", anchor: "associated-records") }
+ = s_('AdminUsers|For more information, please refer to the %{link_start}user account deletion documentation.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 2e179d2d845..b86abb893a9 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -31,7 +31,7 @@
= s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do
- = link_to admin_users_path(filter: "blocked_pending_approval") do
+ = link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do
= s_('AdminUsers|Pending approval')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval)
= nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do
@@ -69,7 +69,11 @@
= link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
= title
-- if @users.empty?
+- if Feature.enabled?(:vue_admin_users)
+ #js-admin-users-app{ data: admin_users_data_attributes(@users) }
+ .gl-spinner-container.gl-my-7
+ %span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } }
+- elsif @users.empty?
.nothing-here-block.border-top-0
= s_('AdminUsers|No users found')
- else
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index 9c6f151a6b1..26f78ea4d6a 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -42,7 +42,7 @@
= sprite_icon('close', size: 16, css_class: 'gl-icon')
%li
%span.light ID:
- %strong
+ %strong{ data: { qa_selector: 'user_id_content' } }
= @user.id
%li
%span.light= _('Namespace ID:')
@@ -158,24 +158,21 @@
.card-body
= render partial: 'admin/users/user_activation_effects'
%br
- = link_to 'Activate user', activate_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
+ %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_activation_data(@user) }
+ = s_('AdminUsers|Activate user')
- elsif @user.can_be_deactivated?
.card.border-warning
.card-header.bg-warning.text-white
Deactivate this user
.card-body
- = render partial: 'admin/users/user_deactivation_effects'
+ = user_deactivation_effects
%br
- %button.btn.gl-button.btn-warning{ data: { 'gl-modal-action': 'deactivate',
- content: 'You can always re-activate their account, their data will remain intact.',
- url: deactivate_admin_user_path(@user),
- username: sanitize_name(@user.name) } }
+ %button.btn.gl-button.btn-warning.js-confirm-modal-button{ data: user_deactivation_data(@user, s_('AdminUsers|You can always re-activate their account, their data will remain intact.')) }
= s_('AdminUsers|Deactivate user')
-
- if @user.blocked?
- if @user.blocked_pending_approval?
= render 'admin/users/approve_user', user: @user
- = render 'admin/users/block_user', user: @user
+ = render 'admin/users/reject_pending_user', user: @user
- else
.card.border-info
.card-header.gl-bg-blue-500.gl-text-white
@@ -186,7 +183,8 @@
%li Log in
%li Access Git repositories
%br
- = link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: s_('AdminUsers|Are you sure?') }
+ %button.btn.gl-button.btn-info.js-confirm-modal-button{ data: user_unblock_data(@user) }
+ = s_('AdminUsers|Unblock user')
- elsif !@user.internal?
= render 'admin/users/block_user', user: @user
@@ -198,52 +196,52 @@
%p This user has been temporarily locked due to excessive number of failed logins. You may manually unlock the account.
%br
= link_to 'Unlock user', unlock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
-
- .card.border-danger
- .card-header.bg-danger.text-white
- = s_('AdminUsers|Delete user')
- .card-body
- - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
- %p Deleting a user has the following effects:
- = render 'users/deletion_guidance', user: @user
- %br
- %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete',
- delete_user_url: admin_user_path(@user),
- block_user_url: block_admin_user_path(@user),
- username: sanitize_name(@user.name) } }
- = s_('AdminUsers|Delete user')
- - else
- - if @user.solo_owned_groups.present?
- %p
- This user is currently an owner in these groups:
- %strong= @user.solo_owned_groups.map(&:name).join(', ')
+ - if !@user.blocked_pending_approval?
+ .card.border-danger
+ .card-header.bg-danger.text-white
+ = s_('AdminUsers|Delete user')
+ .card-body
+ - if @user.can_be_removed? && can?(current_user, :destroy_user, @user)
+ %p Deleting a user has the following effects:
+ = render 'users/deletion_guidance', user: @user
+ %br
+ %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete',
+ delete_user_url: admin_user_path(@user),
+ block_user_url: block_admin_user_path(@user),
+ username: sanitize_name(@user.name) } }
+ = s_('AdminUsers|Delete user')
+ - else
+ - if @user.solo_owned_groups.present?
+ %p
+ This user is currently an owner in these groups:
+ %strong= @user.solo_owned_groups.map(&:name).join(', ')
+ %p
+ You must transfer ownership or delete these groups before you can delete this user.
+ - else
+ %p
+ You don't have access to delete this user.
+
+ .card.border-danger
+ .card-header.bg-danger.text-white
+ = s_('AdminUsers|Delete user and contributions')
+ .card-body
+ - if can?(current_user, :destroy_user, @user)
%p
- You must transfer ownership or delete these groups before you can delete this user.
+ This option deletes the user and any contributions that
+ would usually be moved to the
+ = succeed "." do
+ = link_to "system ghost user", help_page_path("user/profile/account/delete_account")
+ As well as the user's personal projects, groups owned solely by
+ the user, and projects in them, will also be removed. Commits
+ to other projects are unaffected.
+ %br
+ %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
+ delete_user_url: admin_user_path(@user, hard_delete: true),
+ block_user_url: block_admin_user_path(@user),
+ username: @user.name } }
+ = s_('AdminUsers|Delete user and contributions')
- else
%p
You don't have access to delete this user.
- .card.border-danger
- .card-header.bg-danger.text-white
- = s_('AdminUsers|Delete user and contributions')
- .card-body
- - if can?(current_user, :destroy_user, @user)
- %p
- This option deletes the user and any contributions that
- would usually be moved to the
- = succeed "." do
- = link_to "system ghost user", help_page_path("user/profile/account/delete_account")
- As well as the user's personal projects, groups owned solely by
- the user, and projects in them, will also be removed. Commits
- to other projects are unaffected.
- %br
- %button.delete-user-button.btn.gl-button.btn-danger{ data: { 'gl-modal-action': 'delete-with-contributions',
- delete_user_url: admin_user_path(@user, hard_delete: true),
- block_user_url: block_admin_user_path(@user),
- username: @user.name } }
- = s_('AdminUsers|Delete user and contributions')
- - else
- %p
- You don't have access to delete this user.
-
= render partial: 'admin/users/modals'
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index d1ea7fec49d..573b96caae5 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -32,7 +32,7 @@
%button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true }
%span.dropdown-toggle-text
= _('Select project')
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%span.form-text.text-muted &nbsp;
.form-group
@@ -43,7 +43,7 @@
%button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true }
%span.dropdown-toggle-text
= _('Select project to choose zone')
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%p.form-text.text-muted
= s_('ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: zones_link_url }, help_link_end: help_link_end }
@@ -59,7 +59,7 @@
%button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true }
%span.dropdown-toggle-text
= _('Select project and zone to choose machine type')
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%p.form-text.text-muted
= s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end }
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 6ac852af2db..cb464eeafbb 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -27,6 +27,7 @@
provider_type: @cluster.provider_type,
pre_installed_knative: @cluster.knative_pre_installed? ? 'true': 'false',
help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
+ helm_help_path: help_page_path('user/clusters/applications.md', anchor: 'helm'),
ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'),
ingress_dns_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint'),
ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'),
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index 9a9fbfc1ee8..c34e457dbd9 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -71,7 +71,7 @@
= sort_options_hash[@sort]
- else
= sort_title_recently_created
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-sort.dropdown-menu-right
%li
= link_to todos_filter_path(sort: sort_value_label_priority) do
@@ -85,9 +85,8 @@
- if @todos.any?
.js-todos-list-container{ data: { qa_selector: "todos_list_container" } }
.js-todos-options{ data: { per_page: @todos.limit_value, current_page: @todos.current_page, total_pages: @todos.total_pages } }
- .card.card-without-border.card-without-margin
- %ul.content-list.todos-list
- = render @todos
+ %ul.content-list.todos-list
+ = render @todos
= paginate @todos, theme: "gitlab"
.js-nothing-here-container.todos-all-done.hidden.svg-content
= image_tag 'illustrations/todos_all_done.svg'
diff --git a/app/views/devise/confirmations/almost_there.haml b/app/views/devise/confirmations/almost_there.haml
index a1fcbea5bf2..bf321bb690b 100644
--- a/app/views/devise/confirmations/almost_there.haml
+++ b/app/views/devise/confirmations/almost_there.haml
@@ -1,7 +1,7 @@
-.well-confirmation.text-center.append-bottom-20
+.well-confirmation.text-center.gl-mb-6
%h1.gl-mt-0
Almost there...
- %p.lead.append-bottom-20
+ %p.lead.gl-mb-6
Please check your email to confirm your account
%hr
- if Gitlab::CurrentSettings.after_sign_up_text.present?
@@ -9,6 +9,6 @@
= markdown_field(Gitlab::CurrentSettings, :after_sign_up_text)
%p.text-center
No confirmation email received? Please check your spam folder or
-.append-bottom-20.prepend-top-20.text-center
+.gl-mb-6.prepend-top-20.text-center
%a.btn.btn-lg.btn-success{ href: new_user_confirmation_path }
Request new confirmation email
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 0dc98001881..3c4cbbbc3bd 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,7 +1,12 @@
- max_first_name_length = max_last_name_length = 127
- max_username_length = 255
- min_username_length = 2
+- omniauth_providers_placement ||= :bottom
+
.gl-mb-3.gl-p-4.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base
+ - if show_omniauth_providers && omniauth_providers_placement == :top
+ = render 'devise/shared/signup_omniauth_providers_top'
+
= form_for(resource, as: "new_#{resource_name}", url: url, html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f|
.devise-errors
= render 'devise/shared/error_messages', resource: resource
@@ -23,7 +28,7 @@
.form-group
= f.label :email, class: 'label-bold'
= f.email_field :email, value: @invite_email, class: 'form-control middle', data: { qa_selector: 'new_user_email_field' }, required: true, title: _('Please provide a valid email address.')
- .form-group.append-bottom-20#password-strength
+ .form-group.gl-mb-5#password-strength
= f.label :password, class: 'label-bold'
= f.password_field :password, class: 'form-control bottom', data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }
@@ -33,5 +38,5 @@
.submit-container
= f.submit button_text, class: 'btn gl-button btn-success', data: { qa_selector: 'new_user_register_button' }
= render 'devise/shared/terms_of_service_notice'
- - if show_omniauth_providers
+ - if show_omniauth_providers && omniauth_providers_placement == :bottom
= render 'devise/shared/signup_omniauth_providers'
diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml
new file mode 100644
index 00000000000..ece886b3cdd
--- /dev/null
+++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml
@@ -0,0 +1,9 @@
+%label.label-bold.d-block
+ = _("Create an account using:")
+.d-flex.justify-content-between.flex-wrap
+ - providers.each do |provider|
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn gl-button gl-display-flex gl-align-items-center gl-text-left gl-mb-2 gl-p-2 omniauth-btn oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
+ - if provider_has_icon?(provider)
+ = provider_image_tag(provider)
+ %span.ml-2
+ = label_for_provider(provider)
diff --git a/app/views/devise/shared/_signup_omniauth_providers.haml b/app/views/devise/shared/_signup_omniauth_providers.haml
index 68098f1865b..a653d44d694 100644
--- a/app/views/devise/shared/_signup_omniauth_providers.haml
+++ b/app/views/devise/shared/_signup_omniauth_providers.haml
@@ -1,13 +1,3 @@
.omniauth-divider.d-flex.align-items-center.text-center
= _("or")
-%label.label-bold.d-block
- = _("Create an account using:")
-- providers = enabled_button_based_providers
-.d-flex.justify-content-between.flex-wrap
- - providers.each do |provider|
- - has_icon = provider_has_icon?(provider)
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: "gl-button btn d-flex align-items-center omniauth-btn text-left oauth-login mb-2 p-2 #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
- - if has_icon
- = provider_image_tag(provider)
- %span.ml-2
- = label_for_provider(provider)
+= render 'devise/shared/signup_omniauth_provider_list', providers: enabled_button_based_providers
diff --git a/app/views/devise/shared/_signup_omniauth_providers_top.haml b/app/views/devise/shared/_signup_omniauth_providers_top.haml
new file mode 100644
index 00000000000..1deacad88c4
--- /dev/null
+++ b/app/views/devise/shared/_signup_omniauth_providers_top.haml
@@ -0,0 +1,3 @@
+= render 'devise/shared/signup_omniauth_provider_list', providers: experiment_enabled_button_based_providers
+.omniauth-divider.d-flex.align-items-center.text-center
+ = _("or")
diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml
index 96f4f07176e..d145ac3f359 100644
--- a/app/views/devise/unlocks/new.html.haml
+++ b/app/views/devise/unlocks/new.html.haml
@@ -4,7 +4,7 @@
= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
.devise-errors
= render "devise/shared/error_messages", resource: resource
- .form-group.append-bottom-20
+ .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.'
.clearfix
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index bf17eb4fe3e..b5bfbc7bd1c 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -10,7 +10,7 @@
- if current_user.admin?
.text-warning
%p
- = icon("exclamation-triangle fw")
+ = sprite_icon('warning-solid')
= html_escape(_('You are an admin, which means granting access to %{client_name} will allow them to interact with GitLab as an admin as well. Proceed with caution.')) % { client_name: tag.strong(@pre_auth.client.name) }
%p
- link_to_client = link_to(@pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer')
diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml
index 6fc156cf4ed..2ead8fc2cfd 100644
--- a/app/views/explore/projects/_filter.html.haml
+++ b/app/views/explore/projects/_filter.html.haml
@@ -10,7 +10,7 @@
= visibility_level_label(params[:visibility_level].to_i)
- else
= _('Any')
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right
%li
= link_to filter_projects_path(visibility_level: nil) do
diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml
index 07394eec107..f141b646e69 100644
--- a/app/views/groups/_create_chat_team.html.haml
+++ b/app/views/groups/_create_chat_team.html.haml
@@ -1,10 +1,10 @@
.form-group
.col-sm-2.col-form-label
= f.label :create_chat_team do
- %span.mattermost-icon
+ %span.gl-display-flex
= custom_icon('icon_mattermost')
- Mattermost
- .col-sm-10
+ %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)
= f.label :create_chat_team, class: 'form-check-label' do
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index ee08829d990..67f278a06f3 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -6,10 +6,10 @@
.row.mb-3
.home-panel-title-row.col-md-12.col-lg-6.d-flex
.avatar-container.rect-avatar.s64.home-panel-avatar.gl-mr-3.float-none
- = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64)
+ = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo')
.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
+ %h1.home-panel-title.gl-mt-3.gl-mb-2{ itemprop: 'name' }
= @group.name
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, options: {class: 'icon'})
@@ -34,7 +34,7 @@
- if @group.description.present?
.group-home-desc.mt-1
.home-panel-description
- .home-panel-description-markdown.read-more-container
+ .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" }
= _("Read more")
diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml
new file mode 100644
index 00000000000..c95e7c16161
--- /dev/null
+++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml
@@ -0,0 +1,25 @@
+= form_with url: configure_import_bulk_imports_path, class: 'group-form gl-show-field-errors' do |f|
+ = form_errors(@group)
+
+ .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
+ %h4
+ = s_('GroupsNew|Import groups from another instance of GitLab')
+ %p
+ = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.')
+ .form-group.gl-display-flex.gl-flex-direction-column
+ = f.label :bulk_import_gitlab_url, s_('GroupsNew|GitLab source URL'), for: 'import_gitlab_url'
+ = f.text_field :bulk_import_gitlab_url, placeholder: 'https://gitlab.example.com', class: 'gl-form-input col-xs-12 col-sm-8',
+ required: true,
+ title: s_('GroupsNew|Please fill in GitLab source URL.'),
+ id: 'import_gitlab_url'
+ .form-group.gl-display-flex.gl-flex-direction-column
+ = f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token'
+ .gl-font-weight-normal
+ - pat_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/profile/personal_access_tokens') }
+ = s_('GroupsNew|Navigate to user settings to find your %{link_start}personal access token%{link_end}.').html_safe % { link_start: pat_link_start, link_end: '</a>'.html_safe }
+ = f.text_field :bulk_import_gitlab_access_token, placeholder: s_('GroupsNew|e.g. h8d3f016698e...'), class: 'gl-form-input gl-mt-3 col-xs-12 col-sm-8',
+ required: true,
+ title: s_('GroupsNew|Please fill in your personal access token.'),
+ id: 'import_gitlab_token'
+ .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5
+ = f.submit s_('GroupsNew|Connect instance'), class: 'btn gl-button btn-success'
diff --git a/app/views/groups/_import_group_pane.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml
index 9ad8ebbb37d..171f3e0371a 100644
--- a/app/views/groups/_import_group_pane.html.haml
+++ b/app/views/groups/_import_group_from_file_panel.html.haml
@@ -5,18 +5,22 @@
= form_with url: import_gitlab_group_path, class: 'group-form gl-show-field-errors', multipart: true do |f|
= form_errors(@group)
- .row
- .form-group.group-name.col-sm-12
- = f.label :name, _('Group name'), class: 'label-bold'
- = f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name form-control input-lg',
+ .gl-border-l-solid.gl-border-r-solid.gl-border-gray-100.gl-border-1.gl-p-5
+ %h4
+ = _('Import group from file')
+ %p
+ = s_('GroupsNew|Provide credentials for another instance of GitLab to import your groups directly.')
+ .form-group.gl-display-flex.gl-flex-direction-column
+ = f.label :name, _('New group name'), for: 'import_group_name'
+ = f.text_field :name, placeholder: s_('GroupsNew|My Awesome Group'), class: 'js-autofill-group-name gl-form-input col-xs-12 col-sm-8',
required: true,
title: _('Please fill in a descriptive name for your group.'),
- autofocus: true
+ autofocus: true,
+ id: 'import_group_name'
- .row
- .form-group.col-xs-12.col-sm-8
- = f.label :path, _('Group URL'), class: 'label-bold'
- .input-group.gl-field-error-anchor
+ .form-group.gl-display-flex.gl-flex-direction-column
+ = f.label :import_group_path, _('New group URL'), for: 'import_group_path'
+ .input-group.gl-field-error-anchor.col-xs-12.col-sm-8.gl-p-0
.group-root-path.input-group-prepend.has-tooltip{ title: group_path, :'data-placement' => 'bottom' }
.input-group-text
%span
@@ -35,18 +39,12 @@
%span.gl-path-suggestions
%p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...')
-
- .row
- .form-group.col-md-12
- = s_('GroupsNew|To copy a GitLab group between installations, navigate to the group settings page for the original installation, generate an export file, and upload it here.')
- .row
- .form-group.col-sm-12
- = f.label :file, s_('GroupsNew|Import a GitLab group export file'), class: 'label-bold'
- %div
- = render 'shared/file_picker_button', f: f, field: :file, help_text: nil
-
- .row
- .form-actions.col-sm-12
- = f.submit s_('GroupsNew|Import group'), class: 'btn btn-success'
- = link_to _('Cancel'), new_group_path, class: 'btn btn-cancel'
-
+ .form-group
+ = f.label :file, s_('GroupsNew|Upload file')
+ .gl-font-weight-normal
+ - import_export_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: help_page_path('user/group/settings/import_export') }
+ = s_('GroupsNew|To import a group, navigate to the group settings for the GitLab source instance, %{link_start}generate an export file%{link_end}, and upload it here.').html_safe % { link_start: import_export_link_start, link_end: '</a>'.html_safe }
+ .gl-mt-3
+ = render 'shared/file_picker_button', f: f, field: :file, help_text: nil, classes: 'gl-button btn-success-secondary gl-mr-2'
+ .gl-border-gray-100.gl-border-solid.gl-border-1.gl-bg-gray-10.gl-p-5
+ = f.submit _('Import'), class: 'btn gl-button btn-success'
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index d9706556e79..3872bbcd062 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -2,12 +2,7 @@
= render 'shared/group_form', f: f, autofocus: true
.row
- .form-group.group-description-holder.col-sm-12
- = f.label :avatar, _("Group avatar"), class: 'label-bold'
- %div
- = render 'shared/choose_avatar_button', f: f
-
- .form-group.col-sm-12
+ .form-group.col-sm-12.gl-mb-0
%label.label-bold
= _('Visibility level')
%p
@@ -15,8 +10,13 @@
= link_to _('View the documentation'), help_page_path("public_access/public_access"), target: '_blank'
= render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group, with_label: false
- = render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled
-
+- if Gitlab.config.mattermost.enabled
+ .row
+ = render 'create_chat_team', f: f
+.row
+ .col-sm-4
+ = render_if_exists 'shared/groups/invite_members'
+.row
.form-actions.col-sm-12
= f.submit _('Create group'), class: "btn btn-success"
= link_to _('Cancel'), dashboard_groups_path, class: 'btn btn-cancel'
diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml
index cb15fe339e1..d9ab828a83b 100644
--- a/app/views/groups/_subgroups_and_projects.html.haml
+++ b/app/views/groups/_subgroups_and_projects.html.haml
@@ -3,6 +3,6 @@
= render "shared/groups/empty_state"
%section{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
- .js-groups-list-holder
+ .js-groups-list-holder{ data: { show_schema_markup: 'true'} }
.loading-container.text-center.prepend-top-20
.spinner.spinner-md
diff --git a/app/views/groups/dependency_proxies/_url.html.haml b/app/views/groups/dependency_proxies/_url.html.haml
index 9242954b684..25a2442f4d4 100644
--- a/app/views/groups/dependency_proxies/_url.html.haml
+++ b/app/views/groups/dependency_proxies/_url.html.haml
@@ -1,4 +1,4 @@
-- proxy_url = "#{group_url(@group)}/dependency_proxy/containers"
+- proxy_url = "#{group_url(@group)}#{DependencyProxy::URL_SUFFIX}"
%h5.prepend-top-20= _('Dependency proxy URL')
diff --git a/app/views/groups/dependency_proxies/show.html.haml b/app/views/groups/dependency_proxies/show.html.haml
index ff1312eb763..2ecf92e0769 100644
--- a/app/views/groups/dependency_proxies/show.html.haml
+++ b/app/views/groups/dependency_proxies/show.html.haml
@@ -7,7 +7,7 @@
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('user/packages/dependency_proxy/index') }
= _('Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
-- if @group.public?
+- if Feature.enabled?(:dependency_proxy_for_private_groups, default_enabled: true) || @group.public?
- if can?(current_user, :admin_dependency_proxy, @group)
= form_for(@dependency_proxy, method: :put, url: group_dependency_proxy_path(@group)) do |f|
.form-group
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index eafee325500..33cd90ce5d3 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -8,7 +8,7 @@
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= _('Naming, visibility')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= _('Collapse')
%p
= _('Update your group name, description, avatar, and visibility.')
@@ -19,7 +19,7 @@
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= _('Permissions, LFS, 2FA')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Advanced permissions, Large File Storage and Two-Factor authentication settings.')
@@ -32,7 +32,7 @@
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= s_('GroupSettings|Badges')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= s_('GroupSettings|Customize your group badges.')
@@ -40,6 +40,7 @@
.settings-content
= render 'shared/badges/badge_settings'
+= render_if_exists 'groups/compliance_frameworks', expanded: expanded
= render_if_exists 'groups/custom_project_templates_setting'
= render_if_exists 'groups/templates_setting', expanded: expanded
@@ -47,7 +48,7 @@
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
= _('Advanced')
- %button.btn.js-settings-toggle{ type: 'button' }
+ %button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _('Perform advanced options such as changing path, transferring, exporting, or removing the group.')
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 2a87b42ef13..a1527a74898 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -4,6 +4,7 @@
- show_access_requests = can_manage_members && @requesters.exists?
- invited_active = params[:search_invited].present? || params[:invited_members_page].present?
- vue_members_list_enabled = Feature.enabled?(:vue_group_members_list, @group, default_enabled: true)
+- filtered_search_enabled = Feature.enabled?(:group_members_filtered_search, @group, default_enabled: true)
- current_user_is_group_owner = @group && @group.has_owner?(current_user)
- form_item_label_css_class = 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center'
@@ -54,20 +55,21 @@
.tab-content
#tab-members.tab-pane{ class: ('active' unless invited_active) }
.card.card-without-border
- = render 'groups/group_members/tab_pane/header' do
- = render 'groups/group_members/tab_pane/title' do
- = html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do
- .gl-px-3.gl-py-2
- .search-control-wrap.gl-relative
- = render 'shared/members/search_field'
- - if can_manage_members
+ - unless filtered_search_enabled
+ = render 'groups/group_members/tab_pane/header' do
+ = render 'groups/group_members/tab_pane/title' do
+ = html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
+ = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do
+ .gl-px-3.gl-py-2
+ .search-control-wrap.gl-relative
+ = render 'shared/members/search_field'
+ - if can_manage_members
+ = render 'groups/group_members/tab_pane/form_item' do
+ = label_tag '2fa', _('2FA'), class: form_item_label_css_class
+ = render 'shared/members/filter_2fa_dropdown'
= render 'groups/group_members/tab_pane/form_item' do
- = label_tag '2fa', _('2FA'), class: form_item_label_css_class
- = render 'shared/members/filter_2fa_dropdown'
- = render 'groups/group_members/tab_pane/form_item' do
- = label_tag :sort_by, _('Sort by'), class: form_item_label_css_class
- = render 'shared/members/sort_dropdown'
+ = label_tag :sort_by, _('Sort by'), class: form_item_label_css_class
+ = render 'shared/members/sort_dropdown'
- if vue_members_list_enabled
.js-group-members-list{ data: group_members_list_data_attributes(@group, @members) }
.loading
@@ -83,9 +85,10 @@
- if @group.shared_with_group_links.any?
#tab-groups.tab-pane
.card.card-without-border
- = render 'groups/group_members/tab_pane/header' do
- = render 'groups/group_members/tab_pane/title' do
- = html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
+ - unless filtered_search_enabled
+ = render 'groups/group_members/tab_pane/header' do
+ = render 'groups/group_members/tab_pane/title' do
+ = html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- if vue_members_list_enabled
.js-group-linked-list{ data: linked_groups_list_data_attributes(@group) }
.loading
@@ -97,11 +100,12 @@
- if show_invited_members
#tab-invited-members.tab-pane{ class: ('active' if invited_active) }
.card.card-without-border
- = render 'groups/group_members/tab_pane/header' do
- = render 'groups/group_members/tab_pane/title' do
- = html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do
- = render 'shared/members/search_field', name: 'search_invited'
+ - unless filtered_search_enabled
+ = render 'groups/group_members/tab_pane/header' do
+ = render 'groups/group_members/tab_pane/title' do
+ = html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
+ = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do
+ = render 'shared/members/search_field', name: 'search_invited'
- if vue_members_list_enabled
.js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members) }
.loading
@@ -117,9 +121,10 @@
- if show_access_requests
#tab-access-requests.tab-pane
.card.card-without-border
- = render 'groups/group_members/tab_pane/header' do
- = render 'groups/group_members/tab_pane/title' do
- = html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
+ - unless filtered_search_enabled
+ = render 'groups/group_members/tab_pane/header' do
+ = render 'groups/group_members/tab_pane/title' do
+ = html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- if vue_members_list_enabled
.js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) }
.loading
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index a231702012c..920a6ccd9ec 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -31,14 +31,17 @@
%span.d-none.d-sm-block= s_('GroupsNew|Import group')
%span.d-block.d-sm-none= s_('GroupsNew|Import')
- .tab-content.gitlab-tab-content
+ .tab-content.gitlab-tab-content.gl-border-none
.tab-pane.js-toggle-container{ id: 'create-group-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' }
= form_for @group, html: { class: 'group-form gl-show-field-errors' } do |f|
= render 'new_group_fields', f: f, group_name_id: 'create-group-name'
- .tab-pane.js-toggle-container{ id: 'import-group-pane', class: active_when(active_tab) == 'import', role: 'tabpanel' }
+ .tab-pane.no-padding.js-toggle-container{ id: 'import-group-pane', class: active_when(active_tab) == 'import', role: 'tabpanel' }
- if import_sources_enabled?
- = render 'import_group_pane', active_tab: active_tab, autofocus: true
+ - if Feature.enabled?(:bulk_import)
+ = render 'import_group_from_another_instance_panel'
+ .gl-mt-7.gl-border-b-solid.gl-border-gray-100.gl-border-1
+ = render 'import_group_from_file_panel'
- else
.nothing-here-block
%h4= s_('GroupsNew|No import options available')
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index 21882c3e3ce..e26b8317c1c 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -16,4 +16,6 @@
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
+ "group_path": @group.full_path,
+ "gid_prefix": container_repository_gid_prefix,
character_error: @character_error.to_s } }
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 9d5ec5008dc..109e7c3831e 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,5 +1,6 @@
-- breadcrumb_title _("Details")
- @content_class = "limit-container-width" unless fluid_layout
+- page_itemtype 'https://schema.org/Organization'
+- @skip_current_level_breadcrumb = true
- if show_thanks_for_purchase_banner?
= render_if_exists 'shared/thanks_for_purchase_banner', plan_title: plan_title, quantity: params[:purchased_quantity].to_i
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index fca73f118b3..4cf08b1d2be 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -1,3 +1,4 @@
+- add_page_specific_style 'page_bundles/import'
- provider = local_assigns.fetch(:provider)
- extra_data = local_assigns.fetch(:extra_data, {})
- filterable = local_assigns.fetch(:filterable, true)
diff --git a/app/views/import/bulk_imports/status.html.haml b/app/views/import/bulk_imports/status.html.haml
index d909f6a13f0..6757c32d1e1 100644
--- a/app/views/import/bulk_imports/status.html.haml
+++ b/app/views/import/bulk_imports/status.html.haml
@@ -1 +1,12 @@
-- page_title 'Bulk Import'
+- add_to_breadcrumbs 'New group', admin_users_path
+- add_page_specific_style 'page_bundles/import'
+- breadcrumb_title _('Import groups')
+
+%h1.gl-my-0.gl-py-4.gl-font-size-h1.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1
+ = s_('BulkImport|Import groups from GitLab')
+%p.gl-my-0.gl-py-5.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1
+ = s_('BulkImport|Importing groups from %{link}').html_safe % { link: external_link(@source_url, @source_url) }
+
+#import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json),
+ available_namespaces_path: import_available_namespaces_path(format: :json),
+ create_bulk_import_path: import_bulk_imports_path(format: :json) } }
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index ba6a5657d12..b62f98f5ded 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -7,4 +7,6 @@
= sprite_icon('github', css_class: 'gl-mr-2')
= _('Import repositories from GitHub')
-= render 'import/githubish_status', provider: 'github'
+- paginatable = Feature.enabled?(:remove_legacy_github_client)
+
+= render 'import/githubish_status', provider: 'github', paginatable: paginatable
diff --git a/app/views/import/google_code/new.html.haml b/app/views/import/google_code/new.html.haml
deleted file mode 100644
index 1edd224956c..00000000000
--- a/app/views/import/google_code/new.html.haml
+++ /dev/null
@@ -1,63 +0,0 @@
-- page_title _("Google Code import")
-- header_title _("Projects"), root_path
-%h3.page-title.gl-display-flex
- .gl-display-flex.gl-align-items-center.gl-justify-content-center
- = sprite_icon('google', css_class: 'gl-mr-2')
- = _('Import projects from Google Code')
-%hr
-
-= form_tag callback_import_google_code_path, multipart: true do
- %p
- = _('Follow the steps below to export your Google Code project data.')
- = _("In the next step, you'll be able to select the projects you want to import.")
- %ol
- %li
- %p
- - link_to_google_takeout = link_to(_("Google Takeout"), "https://www.google.com/settings/takeout", target: '_blank', rel: 'noopener noreferrer')
- = _("Go to %{link_to_google_takeout}.").html_safe % { link_to_google_takeout: link_to_google_takeout }
- %li
- %p
- = _("Make sure you're logged into the account that owns the projects you'd like to import.")
- %li
- %p
- = html_escape(_('Click the %{strong_open}Select none%{strong_close} button on the right, since we only need "Google Code Project Hosting".')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- %li
- %p
- = html_escape(_('Scroll down to %{strong_open}Google Code Project Hosting%{strong_close} and enable the switch on the right.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- %li
- %p
- = html_escape(_('Choose %{strong_open}Next%{strong_close} at the bottom of the page.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- %li
- %p
- = _('Leave the "File type" and "Delivery method" options on their default values.')
- %li
- %p
- = html_escape(_('Choose %{strong_open}Create archive%{strong_close} and wait for archiving to complete.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- %li
- %p
- = html_escape(_('Click the %{strong_open}Download%{strong_close} button and wait for downloading to complete.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- %li
- %p
- = _('Find the downloaded ZIP file and decompress it.')
- %li
- %p
- = html_escape(_('Find the newly extracted %{code_open}Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json%{code_close} file.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- %li
- %p
- = html_escape(_('Upload %{code_open}GoogleCodeProjectHosting.json%{code_close} here:')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- %p
- %input{ type: "file", name: "dump_file", id: "dump_file" }
- %li
- %p
- = _('Do you want to customize how Google Code email addresses and usernames are imported into GitLab?')
- %p
- = label_tag :create_user_map_0 do
- = radio_button_tag :create_user_map, 0, true
- = _('No, directly import the existing email addresses and usernames.')
- %p
- = label_tag :create_user_map_1 do
- = radio_button_tag :create_user_map, 1, false
- = _('Yes, let me map Google Code users to full names or GitLab users.')
-
- %span
- = submit_tag _('Continue to the next step'), class: "btn btn-success"
diff --git a/app/views/import/google_code/new_user_map.html.haml b/app/views/import/google_code/new_user_map.html.haml
deleted file mode 100644
index 833987dea4e..00000000000
--- a/app/views/import/google_code/new_user_map.html.haml
+++ /dev/null
@@ -1,37 +0,0 @@
-- page_title _("User map"), _("Google Code import")
-- header_title _("Projects"), root_path
-%h3.page-title.gl-display-flex
- .gl-display-flex.gl-align-items-center.gl-justify-content-center
- = sprite_icon('google', css_class: 'gl-mr-2')
- = _('Import projects from Google Code')
-%hr
-
-= form_tag create_user_map_import_google_code_path do
- %p
- = _("Customize how Google Code email addresses and usernames are imported into GitLab. In the next step, you'll be able to select the projects you want to import.")
- %p
- = html_escape(_("The user map is a JSON document mapping the Google Code users that participated on your projects to the way their email addresses and usernames will be imported into GitLab. You can change this by changing the value on the right hand side of %{code_open}:%{code_close}. Be sure to preserve the surrounding double quotes, other punctuation and the email address or username on the left hand side.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- %ul
- %li
- %strong= _("Default: Directly import the Google Code email address or username")
- %p
- = html_escape(_('%{code_open}"johnsmith@example.com": "johnsm...@example.com"%{code_close} will add "By johnsm...@example.com" to all issues and comments originally created by johnsmith@example.com. The email address or username is masked to ensure the user\'s privacy.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- %li
- %strong= _("Map a Google Code user to a GitLab user")
- %p
- = html_escape(_('%{code_open}"johnsmith@example.com": "@johnsmith"%{code_close} will add "By %{link_open}@johnsmith%{link_close}" to all issues and comments originally created by johnsmith@example.com, and will set %{link_open}@johnsmith%{link_close} as the assignee on all issues originally assigned to johnsmith@example.com.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe, link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe }
- %li
- %strong= _("Map a Google Code user to a full name")
- %p
- = html_escape(_('%{code_open}"johnsmith@example.com": "John Smith"%{code_close} will add "By John Smith" to all issues and comments originally created by johnsmith@example.com.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
- %li
- %strong= _("Map a Google Code user to a full email address")
- %p
- = html_escape(_('%{code_open}"johnsmith@example.com": "johnsmith@example.com"%{code_close} will add "By %{link_open}johnsmith@example.com%{link_close}" to all issues and comments originally created by johnsmith@example.com. By default, the email address or username is masked to ensure the user\'s privacy. Use this option if you want to show the full email address.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe, link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe }
-
- .form-group.row
- .col-sm-12
- = text_area_tag :user_map, Gitlab::Json.pretty_generate(@user_map), class: 'form-control', rows: 15
-
- .form-actions
- = submit_tag _('Continue to the next step'), class: "btn btn-success"
diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml
deleted file mode 100644
index 0004f0de69f..00000000000
--- a/app/views/import/google_code/status.html.haml
+++ /dev/null
@@ -1,78 +0,0 @@
-- page_title _("Google Code import")
-- header_title _("Projects"), root_path
-%h3.page-title.gl-display-flex
- .gl-display-flex.gl-align-items-center.gl-justify-content-center
- = sprite_icon('google', css_class: 'gl-mr-2')
- = _('Import projects from Google Code')
-
-- if @repos.any?
- %p.light
- = _('Select projects you want to import.')
- %p.light
- - link_to_customize = link_to(_("customize"), new_user_map_import_google_code_path)
- = _("Optionally, you can %{link_to_customize} how Google Code email addresses and usernames are imported into GitLab.").html_safe % { link_to_customize: link_to_customize }
- %hr
- %p
- - if @incompatible_repos.any?
- = button_tag class: "btn btn-import btn-success js-import-all" do
- = _("Import all compatible projects")
- = loading_icon(css_class: 'loading-icon')
- - else
- = button_tag class: "btn btn-import btn-success js-import-all" do
- = _("Import all projects")
- = loading_icon(css_class: 'loading-icon')
-
-.table-responsive
- %table.table.import-jobs
- %colgroup.import-jobs-from-col
- %colgroup.import-jobs-to-col
- %colgroup.import-jobs-status-col
- %thead
- %tr
- %th= _("From Google Code")
- %th= _("To GitLab")
- %th= _("Status")
- %tbody
- - @already_added_projects.each do |project|
- %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" }
- %td
- = link_to project.import_source, "https://code.google.com/p/#{project.import_source}", target: "_blank", rel: 'noopener noreferrer'
- %td
- = link_to project.full_path, project
- %td.job-status
- - case project.import_status
- - when 'finished'
- %span
- = sprite_icon('check')
- = _("done")
- - when 'started'
- = loading_icon
- = _("started")
- - else
- = project.human_import_status_name
-
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo.id}" }
- %td
- = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank", rel: 'noopener noreferrer'
- %td.import-target
- #{current_user.username}/#{repo.name}
- %td.import-actions.job-status
- = button_tag class: "btn btn-import js-add-to-import" do
- = _("Import")
- = loading_icon(css_class: 'loading-icon')
- - @incompatible_repos.each do |repo|
- %tr{ id: "repo_#{repo.id}" }
- %td
- = link_to repo.name, "https://code.google.com/p/#{repo.name}", target: "_blank", rel: 'noopener noreferrer'
- %td.import-target
- %td.import-actions-job-status
- = label_tag _("Incompatible Project"), nil, class: "label badge-danger"
-
-- if @incompatible_repos.any?
- %p
- = _("One or more of your Google Code projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git.")
- - link_to_import_flow = link_to(_("import flow"), new_import_google_code_path)
- = _("Please convert them to Git on Google Code, and go through the %{link_to_import_flow} again.").html_safe % { link_to_import_flow: link_to_import_flow }
-
-.js-importer-status{ data: { jobs_import_path: "#{jobs_import_google_code_path}", import_path: "#{import_google_code_path}" } }
diff --git a/app/views/import/manifest/_form.html.haml b/app/views/import/manifest/_form.html.haml
index 2ee964974c3..1a3b945cfe5 100644
--- a/app/views/import/manifest/_form.html.haml
+++ b/app/views/import/manifest/_form.html.haml
@@ -19,5 +19,5 @@
= link_to sprite_icon('question-o'), help_page_path('user/project/import/manifest')
.gl-mb-3
- = submit_tag _('List available repositories'), class: 'btn btn-success'
- = link_to _('Cancel'), new_project_path, class: 'btn btn-cancel'
+ = submit_tag _('List available repositories'), class: 'gl-button btn btn-success'
+ = link_to _('Cancel'), new_project_path, class: 'gl-button btn btn-default btn-cancel'
diff --git a/app/views/jira_connect/subscriptions/index.html.haml b/app/views/jira_connect/subscriptions/index.html.haml
index 355ffabd7ec..b826a1b6fc6 100644
--- a/app/views/jira_connect/subscriptions/index.html.haml
+++ b/app/views/jira_connect/subscriptions/index.html.haml
@@ -62,5 +62,4 @@
= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
= webpack_bundle_tag 'jira_connect_app'
-= page_specific_javascript_tag('jira_connect.js')
-- add_page_specific_style 'page_bundles/jira_connect'
+- add_page_specific_style 'page_bundles/jira_connect', defer: false
diff --git a/app/views/layouts/_google_analytics.html.haml b/app/views/layouts/_google_analytics.html.haml
index e8a5359e791..759e9ef36b9 100644
--- a/app/views/layouts/_google_analytics.html.haml
+++ b/app/views/layouts/_google_analytics.html.haml
@@ -1,4 +1,4 @@
-= javascript_tag nonce: true do
+= javascript_tag do
:plain
var _gaq = _gaq || [];
_gaq.push(['_setAccount', '#{extra_config.google_analytics_id}']);
diff --git a/app/views/layouts/_google_tag_manager_head.html.haml b/app/views/layouts/_google_tag_manager_head.html.haml
index ab03f1e7670..48eb9e40cc4 100644
--- a/app/views/layouts/_google_tag_manager_head.html.haml
+++ b/app/views/layouts/_google_tag_manager_head.html.haml
@@ -1,5 +1,5 @@
- if google_tag_manager_enabled?
- = javascript_tag nonce: true do
+ = javascript_tag do
:plain
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 1d12b30c58c..bdd506ab3be 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -88,5 +88,5 @@
= yield :meta_tags
= render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id')
- = render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id')
+ = render 'layouts/matomo' if extra_config.has_key?('matomo_url') && extra_config.has_key?('matomo_site_id')
= render 'layouts/snowplow'
diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml
index cddcd6e0af6..f6d7d163e6f 100644
--- a/app/views/layouts/_img_loader.html.haml
+++ b/app/views/layouts/_img_loader.html.haml
@@ -1,4 +1,4 @@
-= javascript_tag nonce: true do
+= javascript_tag do
:plain
if ('loading' in HTMLImageElement.prototype) {
document.querySelectorAll('img.lazy').forEach(img => {
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 82ec92988eb..509f5be8097 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -4,7 +4,7 @@
- datasources = autocomplete_data_sources(object, noteable_type)
- if object
- = javascript_tag nonce: true do
+ = javascript_tag do
:plain
gl = window.gl || {};
gl.GfmAutoComplete = gl.GfmAutoComplete || {};
diff --git a/app/views/layouts/_init_client_detection_flags.html.haml b/app/views/layouts/_init_client_detection_flags.html.haml
index 6537b86085f..03967bbbfcf 100644
--- a/app/views/layouts/_init_client_detection_flags.html.haml
+++ b/app/views/layouts/_init_client_detection_flags.html.haml
@@ -1,7 +1,7 @@
- client = client_js_flags
- if client
- = javascript_tag nonce: true do
+ = javascript_tag do
:plain
gl = window.gl || {};
gl.client = #{client.to_json};
diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml
index a75b602ff6b..0ef50d1b122 100644
--- a/app/views/layouts/_loading_hints.html.haml
+++ b/app/views/layouts/_loading_hints.html.haml
@@ -6,6 +6,5 @@
- else
%link{ { rel: 'preload', href: stylesheet_url('application'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
%link{ { rel: 'preload', href: stylesheet_url("highlight/themes/#{user_color_scheme}"), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
-%link{ { rel: 'preload', href: asset_url("fontawesome-webfont.woff2?v=4.7.0"), as: 'font', type: 'font/woff2' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
- if Gitlab::CurrentSettings.snowplow_enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname
%link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' }
diff --git a/app/views/layouts/_matomo.html.haml b/app/views/layouts/_matomo.html.haml
new file mode 100644
index 00000000000..fcd3156a162
--- /dev/null
+++ b/app/views/layouts/_matomo.html.haml
@@ -0,0 +1,15 @@
+<!-- Matomo -->
+= javascript_tag do
+ :plain
+ var _paq = window._paq = window._paq || [];
+ _paq.push(['trackPageView']);
+ _paq.push(['enableLinkTracking']);
+ (function() {
+ var u="//#{extra_config.matomo_url}/";
+ _paq.push(['setTrackerUrl', u+'matomo.php']);
+ _paq.push(['setSiteId', "#{extra_config.matomo_site_id}"]);
+ var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
+ g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
+ })();
+<noscript><p><img src="//#{extra_config.matomo_url}/matomo.php?idsite=#{extra_config.matomo_site_id}" style="border:0;" alt="" /></p></noscript>
+<!-- End Matomo Code -->
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index f6fc49393d8..c552454caa7 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -17,6 +17,7 @@
= render_account_recovery_regular_check
= render_if_exists "layouts/header/ee_subscribable_banner"
= render_if_exists "shared/namespace_storage_limit_alert"
+ = render_if_exists "shared/new_user_signups_cap_reached_alert"
= yield :customize_homepage_banner
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
diff --git a/app/views/layouts/_piwik.html.haml b/app/views/layouts/_piwik.html.haml
deleted file mode 100644
index 361a7b03180..00000000000
--- a/app/views/layouts/_piwik.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-<!-- Piwik -->
-= javascript_tag nonce: true do
- :plain
- var _paq = _paq || [];
- _paq.push(['trackPageView']);
- _paq.push(['enableLinkTracking']);
- (function() {
- var u="//#{extra_config.piwik_url}/";
- _paq.push(['setTrackerUrl', u+'piwik.php']);
- _paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]);
- var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
- g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
- })();
-<noscript><p><img src="//#{extra_config.piwik_url}/piwik.php?idsite=#{extra_config.piwik_site_id}" style="border:0;" alt="" /></p></noscript>
-<!-- End Piwik Code -->
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index d7ff5ad1094..9d14dfb3786 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -1,6 +1,6 @@
- return unless Gitlab::CurrentSettings.snowplow_enabled?
-= javascript_tag nonce: true do
+= javascript_tag do
:plain
;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[];
p.GlobalSnowplowNamespace.push(i);p[i]=function(){(p[i].q=p[i].q||[]).push(arguments)
diff --git a/app/views/layouts/_startup_css_activation.haml b/app/views/layouts/_startup_css_activation.haml
index a426d686c34..5fb53385acc 100644
--- a/app/views/layouts/_startup_css_activation.haml
+++ b/app/views/layouts/_startup_css_activation.haml
@@ -1,6 +1,6 @@
- return unless use_startup_css?
-= javascript_tag nonce: true do
+= javascript_tag do
:plain
document.querySelectorAll('link[media="print"]').forEach(linkTag => {
linkTag.setAttribute('data-startupcss', 'loading');
diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml
index 9c488e4f40d..35cd191c600 100644
--- a/app/views/layouts/_startup_js.html.haml
+++ b/app/views/layouts/_startup_js.html.haml
@@ -1,6 +1,6 @@
- return unless page_startup_api_calls.present? || page_startup_graphql_calls.present?
-= javascript_tag nonce: true do
+= javascript_tag do
:plain
var gl = window.gl || {};
gl.startup_calls = #{page_startup_api_calls.to_json};
diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml
index dc924a0e25d..25fe4c898ca 100644
--- a/app/views/layouts/errors.html.haml
+++ b/app/views/layouts/errors.html.haml
@@ -8,7 +8,7 @@
%body
.page-container
= yield
- = javascript_tag nonce: true do
+ = javascript_tag do
:plain
(function(){
var goBackElement = document.querySelector('.js-go-back');
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index 6d2c5870e43..58fed89dfe7 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -8,7 +8,7 @@
- content_for :page_specific_javascripts do
- if current_user
- = javascript_tag nonce: true do
+ = javascript_tag do
:plain
window.uploads_path = "#{group_uploads_path(@group)}";
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index addf2375222..d7ca93a296b 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -18,7 +18,7 @@
- if can?(current_user, :update_user_status, current_user)
%li
%button.btn.menu-item.js-set-status-modal-trigger{ type: 'button' }
- - if current_user.status.present?
+ - if show_status_emoji?(current_user.status) || user_status_set_to_busy?(current_user.status)
= s_('SetStatusModal|Edit status')
- else
= s_('SetStatusModal|Set status')
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 794d1589172..70ab0a56581 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -74,6 +74,7 @@
%span.gl-sr-only
= s_('Nav|Help')
= sprite_icon('question')
+ %span.notification-dot.rounded-circle.gl-absolute
= sprite_icon('chevron-down', css_class: 'caret-down')
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/help_dropdown'
@@ -101,7 +102,7 @@
= sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
- if ::Feature.enabled?(:whats_new_drawer, current_user)
- #whats-new-app{ data: { storage_key: whats_new_storage_key } }
+ #whats-new-app{ data: { storage_key: whats_new_storage_key, versions: whats_new_versions, gitlab_dot_com: Gitlab.dev_env_org_or_com? } }
- if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: user_status_data }
diff --git a/app/views/layouts/jira_connect.html.haml b/app/views/layouts/jira_connect.html.haml
index 17f6e9af61a..0d4ecfc5a10 100644
--- a/app/views/layouts/jira_connect.html.haml
+++ b/app/views/layouts/jira_connect.html.haml
@@ -5,9 +5,11 @@
GitLab
= stylesheet_link_tag 'https://unpkg.com/@atlaskit/css-reset@3.0.6/dist/bundle.css'
= stylesheet_link_tag 'https://unpkg.com/@atlaskit/reduced-ui-pack@10.5.5/dist/bundle.css'
+ = yield :page_specific_styles
+
= javascript_include_tag 'https://connect-cdn.atl-paas.net/all.js'
= javascript_include_tag 'https://unpkg.com/jquery@3.3.1/dist/jquery.min.js'
- = yield :page_specific_styles
+ = Gon::Base.render_data(nonce: content_security_policy_nonce)
= yield :head
%body
.ac-content
diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml
index f0cdb3d1a51..43f1011a85b 100644
--- a/app/views/layouts/nav/_breadcrumbs.html.haml
+++ b/app/views/layouts/nav/_breadcrumbs.html.haml
@@ -1,6 +1,7 @@
- container = @no_breadcrumb_container ? 'container-fluid' : container_class
- hide_top_links = @hide_top_links || false
-- push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link)
+- unless @skip_current_level_breadcrumb
+ - push_to_schema_breadcrumb(@breadcrumb_title, breadcrumb_title_link)
%nav.breadcrumbs{ role: "navigation", class: [container, @content_class] }
.breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) }
@@ -16,8 +17,10 @@
- @breadcrumbs_extra_links.each do |extra|
= breadcrumb_list_item link_to(extra[:text], extra[:link])
= render "layouts/nav/breadcrumbs/collapsed_dropdown", location: :after
- %li
- %h2.breadcrumbs-sub-title= link_to @breadcrumb_title, breadcrumb_title_link
+ - unless @skip_current_level_breadcrumb
+ %li
+ %h2.breadcrumbs-sub-title
+ = link_to @breadcrumb_title, breadcrumb_title_link
%script{ type:'application/ld+json' }
:plain
#{schema_breadcrumb_json}
diff --git a/app/views/layouts/nav/groups_dropdown/_show.html.haml b/app/views/layouts/nav/groups_dropdown/_show.html.haml
index 3ce1fa6bcca..d0394451a61 100644
--- a/app/views/layouts/nav/groups_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/groups_dropdown/_show.html.haml
@@ -3,10 +3,10 @@
.frequent-items-dropdown-sidebar.qa-groups-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/groups#index') do
- = link_to dashboard_groups_path, class: 'qa-your-groups-link' do
+ = link_to dashboard_groups_path, class: 'qa-your-groups-link', data: { track_label: "groups_dropdown_your_groups", track_event: "click_link" } do
= _('Your groups')
= nav_link(path: 'groups#explore') do
- = link_to explore_groups_path do
+ = link_to explore_groups_path, data: { track_label: "groups_dropdown_explore_groups", track_event: "click_link" } do
= _('Explore groups')
.frequent-items-dropdown-content
#js-groups-dropdown{ data: { user_name: current_user.username, group: group_meta } }
diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml
index f2170f71532..91f999a9a74 100644
--- a/app/views/layouts/nav/projects_dropdown/_show.html.haml
+++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml
@@ -3,13 +3,13 @@
.frequent-items-dropdown-sidebar.qa-projects-dropdown-sidebar
%ul
= nav_link(path: 'dashboard/projects#index') do
- = link_to dashboard_projects_path, class: 'qa-your-projects-link' do
+ = link_to dashboard_projects_path, class: 'qa-your-projects-link', data: { track_label: "projects_dropdown_your_projects", track_event: "click_link" } do
= _('Your projects')
= nav_link(path: 'projects#starred') do
- = link_to starred_dashboard_projects_path do
+ = link_to starred_dashboard_projects_path, data: { track_label: "projects_dropdown_starred_projects", track_event: "click_link" } do
= _('Starred projects')
= nav_link(path: 'projects#trending') do
- = link_to explore_root_path do
+ = link_to explore_root_path, data: { track_label: "projects_dropdown_explore_projects", track_event: "click_link" } do
= _('Explore projects')
.frequent-items-dropdown-content
#js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } }
diff --git a/app/views/layouts/nav/sidebar/_analytics_links.html.haml b/app/views/layouts/nav/sidebar/_analytics_links.html.haml
index a99eb8cf457..970a1d5f2c7 100644
--- a/app/views/layouts/nav/sidebar/_analytics_links.html.haml
+++ b/app/views/layouts/nav/sidebar/_analytics_links.html.haml
@@ -4,7 +4,7 @@
- if navbar_links.any?
= nav_link(path: all_paths) do
- = link_to analytics_link.link, { data: { qa_selector: 'analytics_anchor' } } do
+ = link_to analytics_link.link, {class: 'shortcuts-analytics', data: { qa_selector: 'analytics_anchor' } } do
.nav-icon-container
= sprite_icon('chart')
%span.nav-item-name{ data: { qa_selector: 'analytics_link' } }
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 5f4b1f8ad45..efe8e57cadf 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -159,11 +159,10 @@
%span
= _('General')
- - if group_level_integrations?
- = nav_link(controller: :integrations) do
- = link_to group_settings_integrations_path(@group), title: _('Integrations') do
- %span
- = _('Integrations')
+ = nav_link(controller: :integrations) do
+ = link_to group_settings_integrations_path(@group), title: _('Integrations') do
+ %span
+ = _('Integrations')
= nav_link(path: 'groups#projects') do
= link_to projects_group_path(@group), title: _('Projects') do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 5ff774d5d9c..5cadabd5f90 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -262,6 +262,8 @@
%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
@@ -322,7 +324,8 @@
= render_if_exists 'layouts/nav/sidebar/project_packages_link'
- = render 'layouts/nav/sidebar/analytics_links', links: project_analytics_navbar_links(@project, current_user)
+ - 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)
@@ -435,8 +438,6 @@
%span
= _('Pages')
- = render_if_exists 'projects/sidebar/settings_audit_events'
-
= render 'shared/sidebar_toggle_button'
-# Shortcut to Project > Activity
diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml
index 62e5431e290..2df502d2899 100644
--- a/app/views/layouts/project.html.haml
+++ b/app/views/layouts/project.html.haml
@@ -10,7 +10,7 @@
- content_for :project_javascripts do
- project = @target_project || @project
- if current_user
- = javascript_tag nonce: true do
+ = javascript_tag do
:plain
window.uploads_path = "#{project_uploads_path(project)}";
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 6cc53ba3342..54b5ec85ccc 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -4,7 +4,7 @@
- content_for :page_specific_javascripts do
- if snippets_upload_path
- = javascript_tag nonce: true do
+ = javascript_tag do
:plain
window.uploads_path = "#{snippets_upload_path}";
diff --git a/app/views/notify/issue_cloned_email.html.haml b/app/views/notify/issue_cloned_email.html.haml
new file mode 100644
index 00000000000..a9e21e74e22
--- /dev/null
+++ b/app/views/notify/issue_cloned_email.html.haml
@@ -0,0 +1,7 @@
+- author_link = link_to @author.name, user_url(@author)
+- if @can_access_project
+ - string = _("%{author_link} cloned %{original_issue} to %{new_issue}.").html_safe
+- else
+ - string = _("%{author_link} cloned %{original_issue}. You don't have access to the new project.").html_safe
+%p
+ = string % { author_link: author_link, original_issue: issue_reference_link(@issue), new_issue: issue_reference_link(@new_issue, full: true) }
diff --git a/app/views/notify/issue_cloned_email.text.erb b/app/views/notify/issue_cloned_email.text.erb
new file mode 100644
index 00000000000..8d3ff14df5a
--- /dev/null
+++ b/app/views/notify/issue_cloned_email.text.erb
@@ -0,0 +1,8 @@
+Issue was cloned.
+
+<% if @can_access_project %>
+ New issue location:
+ <%= project_issue_url(@new_issue.project, @new_issue) %>
+<% else %>
+ You don't have access to the project.
+<% end %>
diff --git a/app/views/notify/new_release_email.html.haml b/app/views/notify/new_release_email.html.haml
index 45e99f3c07a..9cef4cd85cd 100644
--- a/app/views/notify/new_release_email.html.haml
+++ b/app/views/notify/new_release_email.html.haml
@@ -15,4 +15,4 @@
%p
%h4= _("Release notes:")
- = markdown_field(@release, :description)
+ = markdown(@release.description, pipeline: :email, author: @release.author)
diff --git a/app/views/notify/user_admin_rejection_email.html.haml b/app/views/notify/user_admin_rejection_email.html.haml
new file mode 100644
index 00000000000..24d6c05fa38
--- /dev/null
+++ b/app/views/notify/user_admin_rejection_email.html.haml
@@ -0,0 +1,5 @@
+= email_default_heading(_('Hello %{name},') % { name: @name })
+%p
+ = _('Your request to join %{host} has been rejected.').html_safe % { host: link_to(root_url, root_url) }
+%p
+ = _('Please contact your GitLab administrator if you think this is an error.')
diff --git a/app/views/notify/user_admin_rejection_email.text.erb b/app/views/notify/user_admin_rejection_email.text.erb
new file mode 100644
index 00000000000..cc676b82934
--- /dev/null
+++ b/app/views/notify/user_admin_rejection_email.text.erb
@@ -0,0 +1,6 @@
+<%= _('Hello %{name},') % { name: @name } %>
+
+<%= _('Your request to join %{host} has been rejected.') % { host: root_url } %>
+
+<%= _('Please contact your GitLab administrator if you think this is an error.') %>
+
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index fed40b7f119..ca64c5f57b3 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -7,6 +7,14 @@
.gl-alert-body
= s_('Profiles|Some options are unavailable for LDAP accounts')
+- if params[:two_factor_auth_enabled_successfully]
+ .gl-alert.gl-alert-success.gl-my-5{ role: 'alert' }
+ = sprite_icon('check-circle', size: 16, css_class: 'gl-alert-icon gl-alert-icon-no-title')
+ %button.gl-alert-dismiss.js-close-2fa-enabled-success-alert{ type: 'button', aria: { label: _('Close') } }
+ = sprite_icon('close', size: 16)
+ .gl-alert-body
+ = _('Congratulations! You have enabled Two-factor Authentication!')
+
.row.gl-mt-3
.col-lg-4.profile-settings-sidebar
%h4.gl-mt-0
@@ -71,6 +79,11 @@
%strong= current_user.solo_owned_groups.map(&:name).join(', ')
%p
= s_('Profiles|You must transfer ownership or delete these groups before you can delete your account.')
+ - elsif !current_user.can_remove_self?
+ %p
+ = s_('Profiles|GitLab is unable to verify your identity automatically.')
+ %p
+ = s_('Profiles|Please email %{data_request} to begin the account deletion process.').html_safe % { data_request: mail_to('personal-data-request@gitlab.com') }
- else
%p
= s_("Profiles|You don't have access to delete this user.")
diff --git a/app/views/profiles/keys/_form.html.haml b/app/views/profiles/keys/_form.html.haml
index 6a420d7996a..81a543de7a3 100644
--- a/app/views/profiles/keys/_form.html.haml
+++ b/app/views/profiles/keys/_form.html.haml
@@ -21,7 +21,7 @@
%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.")
- %button.btn.btn-success.js-add-ssh-key-validation-confirm-submit= _("Yes, add it")
+ %button.btn.gl-button.btn-success.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"
diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml
index ea698a296fb..b1578886098 100644
--- a/app/views/profiles/notifications/_group_settings.html.haml
+++ b/app/views/profiles/notifications/_group_settings.html.haml
@@ -12,5 +12,5 @@
= render 'shared/notifications/button', notification_setting: setting, emails_disabled: emails_disabled
.table-section.section-30
- = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f|
+ = form_for setting, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications gl-display-flex' } do |f|
= f.select :notification_email, @user.public_verified_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email'
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 9c5cfe35cda..e1345a94fb1 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -3,10 +3,14 @@
%div
- if @user.errors.any?
- .gl-alert.gl-alert-danger
- %ul
- - @user.errors.full_messages.each do |msg|
- %li= msg
+ .gl-alert.gl-alert-danger.gl-my-5
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', css_class: 'gl-icon')
+ = sprite_icon('error', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ .gl-alert-body
+ %ul
+ - @user.errors.full_messages.each do |msg|
+ %li= msg
= hidden_field_tag :notification_type, 'global'
.row.gl-mt-3
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 11750f2a6d5..577b64ba17a 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -32,22 +32,23 @@
active_tokens: @active_personal_access_tokens,
revoke_route_helper: ->(token) { revoke_profile_personal_access_token_path(token) }
-%hr
-.row.gl-mt-3
- .col-lg-4.profile-settings-sidebar
- %h4.gl-mt-0
- = s_('AccessTokens|Feed token')
- %p
- = s_('AccessTokens|Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.')
- %p
- = s_('AccessTokens|It cannot be used to access any other data.')
- .col-lg-8.feed-token-reset
- = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold'
- = text_field_tag :feed_token, current_user.feed_token, class: 'form-control js-select-on-focus', readonly: true
- %p.form-text.text-muted
- - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') }
- - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link }
- = reset_message.html_safe
+- unless Gitlab::CurrentSettings.disable_feed_token
+ %hr
+ .row.gl-mt-3
+ .col-lg-4.profile-settings-sidebar
+ %h4.gl-mt-0
+ = s_('AccessTokens|Feed token')
+ %p
+ = s_('AccessTokens|Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.')
+ %p
+ = s_('AccessTokens|It cannot be used to access any other data.')
+ .col-lg-8.feed-token-reset
+ = label_tag :feed_token, s_('AccessTokens|Feed token'), class: 'label-bold'
+ = text_field_tag :feed_token, current_user.feed_token, class: 'form-control js-select-on-focus', readonly: true
+ %p.form-text.text-muted
+ - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') }
+ - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link }
+ = reset_message.html_safe
- if incoming_email_token_enabled?
%hr
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index ca5972f1b46..aeecb0c0d72 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -79,13 +79,12 @@
= f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
= f.label :show_whitespace_in_diffs, class: 'form-check-label' do
= s_('Preferences|Show whitespace changes in diffs')
- - if Feature.enabled?(:view_diffs_file_by_file, default_enabled: true)
- .form-group.form-check
- = f.check_box :view_diffs_file_by_file, class: 'form-check-input'
- = f.label :view_diffs_file_by_file, class: 'form-check-label' do
- = s_("Preferences|Show one file at a time on merge request's Changes tab")
- .form-text.text-muted
- = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
+ .form-group.form-check
+ = f.check_box :view_diffs_file_by_file, class: 'form-check-input'
+ = f.label :view_diffs_file_by_file, class: 'form-check-label' do
+ = s_("Preferences|Show one file at a time on merge request's Changes tab")
+ .form-text.text-muted
+ = s_("Preferences|Instead of all the files changed, show only one file at a time. To switch between files, use the file browser.")
.form-group
= f.label :tab_width, s_('Preferences|Tab width'), class: 'label-bold'
= f.number_field :tab_width,
diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml
index 2cb7e022912..178a9d3f8b4 100644
--- a/app/views/profiles/two_factor_auths/_codes.html.haml
+++ b/app/views/profiles/two_factor_auths/_codes.html.haml
@@ -1,13 +1,18 @@
-%p.slead
- - lose_2fa_message = _('Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' }
- = lose_2fa_message.html_safe
+- show_success_alert = local_assigns.fetch(:show_success_alert, nil)
-.codes.card{ data: { qa_selector: 'codes_content' } }
- %ul
- - @codes.each do |code|
- %li
- %span.monospace{ data: { qa_selector: 'code_content' } }= code
+- if Feature.enabled?(:vue_2fa_recovery_codes, current_user, default_enabled: true)
+ .js-2fa-recovery-codes{ data: { codes: @codes.to_json, profile_account_path: profile_account_path(two_factor_auth_enabled_successfully: show_success_alert) } }
+- else
+ %p.slead
+ - lose_2fa_message = _('Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' }
+ = lose_2fa_message.html_safe
-.d-flex
- = link_to _('Proceed'), profile_account_path, class: 'gl-button btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' }
- = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'gl-button btn btn-default'
+ .codes.card{ data: { qa_selector: 'codes_content' } }
+ %ul
+ - @codes.each do |code|
+ %li
+ %span.monospace{ data: { qa_selector: 'code_content' } }= code
+
+ .d-flex
+ = link_to _('Proceed'), profile_account_path, class: 'gl-button btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' }
+ = link_to _('Download codes'), "data:text/plain;charset=utf-8,#{CGI.escape(@codes.join("\n"))}", download: "gitlab-recovery-codes.txt", class: 'gl-button btn btn-default'
diff --git a/app/views/profiles/two_factor_auths/codes.html.haml b/app/views/profiles/two_factor_auths/codes.html.haml
index 53907ebffab..0d8c5ec5dbf 100644
--- a/app/views/profiles/two_factor_auths/codes.html.haml
+++ b/app/views/profiles/two_factor_auths/codes.html.haml
@@ -1,6 +1,4 @@
- page_title _('Recovery Codes'), _('Two-factor Authentication')
+- add_page_specific_style 'page_bundles/profile_two_factor_auth'
-%h3.page-title
- = _('Two-factor Authentication Recovery codes')
-%hr
= render 'codes'
diff --git a/app/views/profiles/two_factor_auths/create.html.haml b/app/views/profiles/two_factor_auths/create.html.haml
index 5a756cca0ab..be4800024cf 100644
--- a/app/views/profiles/two_factor_auths/create.html.haml
+++ b/app/views/profiles/two_factor_auths/create.html.haml
@@ -1,6 +1,8 @@
- page_title _('Two-factor Authentication'), _('Account')
+- add_page_specific_style 'page_bundles/profile_two_factor_auth'
-.gl-alert.gl-alert-success.gl-mb-5
- = _('Congratulations! You have enabled Two-factor Authentication!')
+- unless Feature.enabled?(:vue_2fa_recovery_codes, current_user, default_enabled: true)
+ .gl-alert.gl-alert-success.gl-mb-5
+ = _('Congratulations! You have enabled Two-factor Authentication!')
-= render 'codes'
+= render 'codes', show_success_alert: true
diff --git a/app/views/projects/_archived_notice.html.haml b/app/views/projects/_archived_notice.html.haml
index 522693ae24a..dcece8ab42f 100644
--- a/app/views/projects/_archived_notice.html.haml
+++ b/app/views/projects/_archived_notice.html.haml
@@ -1,5 +1,5 @@
- if project.archived?
.text-warning.center.prepend-top-20
%p
- = icon("exclamation-triangle fw")
+ = sprite_icon('warning-solid')
= _('Archived project! Repository and other project resources are read only')
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 5f7ed46297b..87c0933747d 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -1,7 +1,7 @@
.form-actions
- = button_tag 'Commit changes', id: 'commit-changes', class: 'btn commit-btn js-commit-button btn-success qa-commit-button'
+ = button_tag 'Commit changes', id: 'commit-changes', class: 'gl-button btn btn-success js-commit-button qa-commit-button'
= link_to 'Cancel', cancel_path,
- class: 'btn btn-cancel', data: {confirm: leave_edit_message}
+ class: 'gl-button btn btn-default btn-cancel', data: {confirm: leave_edit_message}
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/_customize_workflow.html.haml b/app/views/projects/_customize_workflow.html.haml
index a41791f0eca..8e4e5ca93e0 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: "btn btn-success"
+ = link_to "Get started", edit_project_path(@project), class: "gl-button btn btn-success"
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 81c42de13f0..88dcc74a465 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -3,14 +3,17 @@
- project = local_assigns.fetch(:project) { @project }
- show_auto_devops_callout = show_auto_devops_callout?(@project)
- add_page_startup_api_call logs_file_project_ref_path(@project, ref, @path, format: "json", offset: 0)
-- if @tree.readme
- - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, @tree.readme.path), viewer: "rich", format: "json")
+- if readme_path = @project.repository.readme_path
+ - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json")
#tree-holder.tree-holder.clearfix
.nav-block
= render 'projects/tree/tree_header', tree: @tree
#js-last-commit
+ .info-well.gl-display-none.gl-display-sm-flex.project-last-commit
+ .gl-spinner-container.m-auto
+ = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom')
- if is_project_overview
.project-buttons.gl-mb-3.js-show-on-project-root
diff --git a/app/views/projects/_fork_suggestion.html.haml b/app/views/projects/_fork_suggestion.html.haml
index 0b616a0c1ce..9e6ff4a5d7a 100644
--- a/app/views/projects/_fork_suggestion.html.haml
+++ b/app/views/projects/_fork_suggestion.html.haml
@@ -6,6 +6,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 btn btn-grouped btn-inverted btn-success'
- %button.js-cancel-fork-suggestion-button.btn.btn-grouped{ type: 'button' }
+ = link_to 'Fork', nil, method: :post, class: 'js-fork-suggestion-button gl-button btn btn-grouped btn-inverted btn-success'
+ %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 569255ec2e5..ebb0dd8b39f 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -19,7 +19,7 @@
= 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' }
+ %span.text-secondary{ itemprop: 'identifier', data: { qa_selector: 'project_id_content' } }
= s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
- if current_user
%span.access-request-links.gl-ml-3
@@ -63,7 +63,7 @@
.home-panel-home-desc.mt-1
- if @project.description.present?
.home-panel-description.text-break
- .home-panel-description-markdown.read-more-container{ itemprop: 'abstract' }
+ .home-panel-description-markdown.read-more-container{ itemprop: 'description' }
= markdown_field(@project, :description)
%button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
= _("Read more")
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 8b94133fd8a..27d75591d3e 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -8,73 +8,77 @@
.import-buttons
- if gitlab_project_import_enabled?
.import_gitlab_project.has-tooltip{ data: { container: 'body' } }
- = link_to new_import_gitlab_project_path, class: 'btn btn_import_gitlab_project project-submit', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do
- = sprite_icon('tanuki')
+ = link_to new_import_gitlab_project_path, class: 'gl-button btn-default btn btn_import_gitlab_project project-submit', **tracking_attrs(track_label, 'click_button', 'gitlab_export') do
+ .gl-button-icon
+ = sprite_icon('tanuki')
= _("GitLab export")
- if github_import_enabled?
%div
- = link_to new_import_github_path, class: 'btn js-import-github', **tracking_attrs(track_label, 'click_button', 'github') do
- = sprite_icon('github')
+ = link_to new_import_github_path, class: 'gl-button btn-default btn js-import-github', **tracking_attrs(track_label, 'click_button', 'github') do
+ .gl-button-icon
+ = sprite_icon('github')
GitHub
- if bitbucket_import_enabled?
%div
- = link_to status_import_bitbucket_path, class: "btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}",
+ = link_to status_import_bitbucket_path, class: "gl-button btn-default btn import_bitbucket #{'how_to_import_link' unless bitbucket_import_configured?}",
**tracking_attrs(track_label, 'click_button', 'bitbucket_cloud') do
- = sprite_icon('bitbucket')
+ .gl-button-icon
+ = sprite_icon('bitbucket')
Bitbucket Cloud
- unless bitbucket_import_configured?
= render 'projects/bitbucket_import_modal'
- if bitbucket_server_import_enabled?
%div
- = link_to status_import_bitbucket_server_path, class: "btn import_bitbucket", **tracking_attrs(track_label, 'click_button', 'bitbucket_server') do
- = sprite_icon('bitbucket')
+ = link_to status_import_bitbucket_server_path, class: "gl-button btn-default btn import_bitbucket", **tracking_attrs(track_label, 'click_button', 'bitbucket_server') do
+ .gl-button-icon
+ = sprite_icon('bitbucket')
Bitbucket Server
%div
- if gitlab_import_enabled?
%div
- = link_to status_import_gitlab_path, class: "btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}",
+ = link_to status_import_gitlab_path, class: "gl-button btn-default btn import_gitlab #{'how_to_import_link' unless gitlab_import_configured?}",
**tracking_attrs(track_label, 'click_button', 'gitlab_com') do
- = sprite_icon('tanuki')
+ .gl-button-icon
+ = sprite_icon('tanuki')
= _("GitLab.com")
- unless gitlab_import_configured?
= render 'projects/gitlab_import_modal'
- - if google_code_import_enabled?
- %div
- = link_to new_import_google_code_path, class: 'btn import_google_code', **tracking_attrs(track_label, 'click_button', 'google_code') do
- = sprite_icon('google')
- Google Code
-
- if fogbugz_import_enabled?
%div
- = link_to new_import_fogbugz_path, class: 'btn import_fogbugz', **tracking_attrs(track_label, 'click_button', 'fogbugz') do
- = sprite_icon('bug')
+ = link_to new_import_fogbugz_path, class: 'gl-button btn-default btn import_fogbugz', **tracking_attrs(track_label, 'click_button', 'fogbugz') do
+ .gl-button-icon
+ = sprite_icon('bug')
FogBugz
- if gitea_import_enabled?
%div
- = link_to new_import_gitea_path, class: 'btn import_gitea', **tracking_attrs(track_label, 'click_button', 'gitea') do
- = custom_icon('gitea_logo')
+ = link_to new_import_gitea_path, class: 'gl-button btn-default btn import_gitea', **tracking_attrs(track_label, 'click_button', 'gitea') do
+ .gl-button-icon
+ = custom_icon('gitea_logo')
Gitea
- if git_import_enabled?
%div
- %button.btn.btn-svg.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') }
- = sprite_icon('link', css_class: 'gl-icon')
+ %button.gl-button.btn-default.btn.btn-svg.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') }
+ .gl-button-icon
+ = sprite_icon('link', css_class: 'gl-icon')
= _('Repo by URL')
- if manifest_import_enabled?
%div
- = link_to new_import_manifest_path, class: 'btn import_manifest', **tracking_attrs(track_label, 'click_button', 'manifest_file') do
- = sprite_icon('doc-text')
+ = link_to new_import_manifest_path, class: 'gl-button btn-default btn import_manifest', **tracking_attrs(track_label, 'click_button', 'manifest_file') do
+ .gl-button-icon
+ = sprite_icon('doc-text')
Manifest file
- if phabricator_import_enabled?
%div
- = link_to new_import_phabricator_path, class: 'btn import_phabricator', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "phabricator" } do
- = custom_icon('issues')
+ = link_to new_import_phabricator_path, class: 'gl-button btn-default btn import_phabricator', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "phabricator" } do
+ .gl-button-icon
+ = custom_icon('issues')
= _("Phabricator Tasks")
diff --git a/app/views/projects/_invite_members.html.haml b/app/views/projects/_invite_members.html.haml
new file mode 100644
index 00000000000..ef030cabc93
--- /dev/null
+++ b/app/views/projects/_invite_members.html.haml
@@ -0,0 +1,8 @@
+%h4.gl-mt-0.gl-mb-3{ data: { testid: 'invite-member-section',
+ track_label: 'invite_members_empty_project',
+ track_event: 'render' } }
+ = 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',
+ data: { track_event: 'click_button', track_label: 'invite_members_empty_project' }
diff --git a/app/views/projects/_project_templates.html.haml b/app/views/projects/_project_templates.html.haml
index 79221c59ae4..d1ff52548cd 100644
--- a/app/views/projects/_project_templates.html.haml
+++ b/app/views/projects/_project_templates.html.haml
@@ -5,17 +5,11 @@
%li.built-in-tab
%a.nav-link.active{ href: "#built-in", data: { toggle: 'tab'} }
= _('Built-in')
- %span.badge.badge-pill= Gitlab::ProjectTemplate.all.count
- %li.sample-data-templates-tab
- %a.nav-link{ href: "#sample-data-templates", data: { toggle: 'tab'} }
- = _('Sample Data')
- %span.badge.badge-pill= Gitlab::SampleDataTemplate.all.count
+ %span.badge.badge-pill= Gitlab::SampleDataTemplate.all.count + Gitlab::ProjectTemplate.all.count
.tab-content
.project-templates-buttons.import-buttons.tab-pane.active#built-in
- = render partial: 'projects/project_templates/template', collection: Gitlab::ProjectTemplate.all
- .project-templates-buttons.import-buttons.tab-pane#sample-data-templates
- = render partial: 'projects/project_templates/template', collection: Gitlab::SampleDataTemplate.all
+ = render partial: 'projects/project_templates/template', collection: Gitlab::SampleDataTemplate.all + Gitlab::ProjectTemplate.all
.project-fields-form
= render 'projects/project_templates/project_fields_form'
diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml
index 7c08955983a..3b2b3a2ba67 100644
--- a/app/views/projects/_service_desk_settings.html.haml
+++ b/app/views/projects/_service_desk_settings.html.haml
@@ -12,6 +12,7 @@
enabled: "#{@project.service_desk_enabled}",
incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled),
custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled),
+ custom_email_enabled: "#{@project.service_desk_custom_address_enabled?}",
selected_template: "#{@project.service_desk_setting&.issue_template_key}",
outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
project_key: "#{@project.service_desk_setting&.project_key}",
diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml
index 5b77e31eb00..7afbd85cd6d 100644
--- a/app/views/projects/blob/_content.html.haml
+++ b/app/views/projects/blob/_content.html.haml
@@ -1,10 +1,6 @@
- simple_viewer = blob.simple_viewer
- rich_viewer = blob.rich_viewer
- rich_viewer_active = rich_viewer && params[:viewer] != 'simple'
-- blob_data = defined?(@blob) ? @blob.data : {}
-- is_ci_config_file = defined?(@blob) && defined?(@project) ? editing_ci_config?.to_s : 'false'
-
-#js-blob-toggle-graph-preview{ data: { blob_data: blob_data, is_ci_config_file: is_ci_config_file } }
= render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active
diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml
index 8e3cf607bbf..c6b13deaece 100644
--- a/app/views/projects/blob/_viewer_switcher.html.haml
+++ b/app/views/projects/blob/_viewer_switcher.html.haml
@@ -8,5 +8,5 @@
= sprite_icon(simple_viewer.switcher_icon)
- rich_label = "Display #{rich_viewer.switcher_title}"
- %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
+ %button.btn.gl-button.btn-default.btn-sm.js-blob-viewer-switch-btn.gl-mr-3.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
= sprite_icon(rich_viewer.switcher_icon)
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 54c47e7af38..abfed450316 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -9,9 +9,8 @@
= link_to "the file", project_blob_path(@project, tree_join(@branch_name, @file_path)), target: "_blank", rel: 'noopener noreferrer', class: 'gl-link'
and make sure your changes will not unintentionally remove theirs.
-.editor-title-row
- %h3.page-title.blob-edit-page-title
- Edit file
+%h3.page-title.blob-edit-page-title
+ Edit file
.file-editor
%ul.nav-links.no-bottom.js-edit-mode.nav.nav-tabs
%li.active
diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml
index 2a33afabb7c..8722819fe4f 100644
--- a/app/views/projects/blob/new.html.haml
+++ b/app/views/projects/blob/new.html.haml
@@ -1,9 +1,8 @@
- breadcrumb_title _("Repository")
- page_title _("New File"), @path.presence, @ref
-.editor-title-row
- %h3.page-title.blob-new-page-title
- New file
+%h3.page-title.blob-new-page-title
+ New file
.file-editor
= form_tag(project_create_blob_path(@project, @id), method: :post, class: 'js-edit-blob-form js-new-blob-form js-quick-submit js-requires-input', data: blob_editor_paths(@project)) do
= render 'projects/blob/editor', ref: @ref
diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml
index aedfb64d3e4..db4b04eaeb8 100644
--- a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml
+++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml
@@ -1,4 +1,4 @@
-= icon('spinner spin fw')
+= loading_icon(css_class: "gl-vertical-align-text-bottom mr-1")
= _('Metrics Dashboard YAML definition') + '…'
= link_to _('Learn more'), help_page_path('operations/metrics/dashboards/yaml.md')
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index cf58cff7445..938dfc69500 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -2,7 +2,7 @@
- dropdown_class = local_assigns.fetch(:dropdown_class, '')
.git-clone-holder.js-git-clone-holder
- %a#clone-dropdown.gl-button.btn.btn-primary.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
+ %a#clone-dropdown.gl-button.btn.btn-info.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%span.gl-mr-2.js-clone-dropdown-label
= _('Clone')
= sprite_icon("chevron-down", css_class: "icon")
@@ -12,7 +12,7 @@
%label.label-bold
= _('Clone with SSH')
.input-group
- = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: 'Project clone URL' }
+ = text_field_tag :ssh_project_clone, project.ssh_url_to_repo, class: "js-select-on-focus form-control qa-ssh-clone-url", readonly: true, aria: { label: _('Repository clone URL') }
.input-group-append
= clipboard_button(target: '#ssh_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'projects/buttons/geo'
@@ -21,7 +21,7 @@
%label.label-bold
= _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase }
.input-group
- = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: 'Project clone URL' }
+ = text_field_tag :http_project_clone, project.http_url_to_repo, class: "js-select-on-focus form-control qa-http-clone-url", readonly: true, aria: { label: _('Repository clone URL') }
.input-group-append
= clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'projects/buttons/geo'
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 138f5569218..8b4411776bc 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -97,7 +97,7 @@
#{job.coverage}%
%td
- .float-right
+ .gl-display-flex
- if can?(current_user, :read_build, job) && job.artifacts?
= link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build gl-button btn-icon btn-svg' do
= sprite_icon('download')
diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml
index 0e032f2575e..f1f8658fa3b 100644
--- a/app/views/projects/ci/pipeline_editor/show.html.haml
+++ b/app/views/projects/ci/pipeline_editor/show.html.haml
@@ -3,4 +3,6 @@
#js-pipeline-editor{ data: { "ci-config-path": @project.ci_config_path_or_default,
"project-path" => @project.full_path,
"default-branch" => @project.default_branch,
+ "commit-id" => @project.commit ? @project.commit.id : '',
+ "new-merge-request-path" => namespace_project_new_merge_request_path,
} }
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 86c80f1a8ae..6f2797654d0 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -30,7 +30,7 @@
.dropdown.inline
%a.btn.gl-button.dropdown-toggle.qa-options-button.d-md-inline{ data: { toggle: "dropdown" } }
%span= _('Options')
- = icon('caret-down')
+ = 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
diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml
index 4964b1b8ee7..357ad467539 100644
--- a/app/views/projects/commit/_verified_signature_badge.html.haml
+++ b/app/views/projects/commit/_verified_signature_badge.html.haml
@@ -1,5 +1,5 @@
- title = capture do
- = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe
+ = html_escape(_('This commit was signed with a %{strong_open}verified%{strong_close} signature and the committer email is verified to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true }
diff --git a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml
index 680cc32c7e6..6204a6977c0 100644
--- a/app/views/projects/commit/x509/_unverified_signature_badge.html.haml
+++ b/app/views/projects/commit/x509/_unverified_signature_badge.html.haml
@@ -1,5 +1,5 @@
- title = capture do
- = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe
+ = html_escape(_('This commit was signed with an %{strong_open}unverified%{strong_close} signature.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true }
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index 63cc96c2c05..a8a928515fe 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -37,7 +37,9 @@
= _('Add previously merged commits')
- if commits.size == 0 && context_commits.nil?
- .mt-4.text-center
- .bold
+ .commits-empty.gl-mt-6
+ = custom_icon('illustration_no_commits')
+ %h4
= _('Your search didn\'t match any commits.')
- = _('Try changing or removing filters.')
+ %p
+ = _('Try changing or removing filters.')
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 94bdab53cd0..a14f75259ec 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -24,7 +24,7 @@
.control
= form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do
- = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control search-text-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false }
+ = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control gl-form-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full gl-inset-border-1-gray-200!', spellcheck: false }
.control.d-none.d-md-block
= link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn gl-button btn-svg' do
= sprite_icon('rss', css_class: 'qa-rss-icon')
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index a257f2e9433..0c0530110c5 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -28,4 +28,4 @@
- if @merge_request.present?
= link_to _("View open merge request"), project_merge_request_path(@project, @merge_request), class: 'gl-ml-3 btn'
- elsif create_mr_button?
- = link_to _("Create merge request"), create_mr_path, class: 'gl-ml-3 btn'
+ = link_to _("Create merge request"), create_mr_path, class: 'gl-ml-3 btn gl-button'
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index b98ab9757fa..fc3710d3609 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -2,13 +2,6 @@
- add_page_specific_style 'page_bundles/cycle_analytics'
#cycle-analytics{ "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } }
- - if @cycle_analytics_no_data
- %banner{ "v-if" => "!isOverviewDialogDismissed",
- "documentation-link": help_page_path('user/analytics/value_stream_analytics.md'),
- "v-on:dismiss-overview-dialog" => "dismissOverviewDialog()" }
- .mb-3
- %h3
- = _("Value Stream Analytics")
%gl-loading-icon{ "v-show" => "isLoading", "size" => "lg" }
.wrapper{ "v-show" => "!isLoading && !hasError" }
.card
@@ -49,7 +42,7 @@
%span.has-tooltip{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" }
= sprite_icon('question-o', css_class: 'gl-text-gray-500')
%li.event-header.pl-3
- %span.stage-name.font-weight-bold
+ %span.stage-name.font-weight-bold{ "v-if" => "currentStage && currentStage.legend" }
{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}
%span.has-tooltip{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" }
= sprite_icon('question-o', css_class: 'gl-text-gray-500')
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index 7f4b99f1a3f..c0fe143020a 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -5,7 +5,7 @@
.dropdown
%button.dropdown.dropdown-new.btn.gl-button.btn-default.has-tooltip{ type: 'button', 'data-toggle' => 'dropdown', title: s_('Environments|Deploy to...') }
= sprite_icon('play')
- = icon('caret-down')
+ = sprite_icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right
- actions.each do |action|
- next unless can?(current_user, :update_build, action)
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 52e3e0fd997..509ed62b39d 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -2,7 +2,7 @@
.branch-commit.cgray
- if deployment.ref
%span.icon-container.gl-display-inline-block
- = deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
+ = deployment.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('fork', css_class: 'sprite')
= link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
.icon-container.commit-icon
= custom_icon("icon_commit")
diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml
index cb43527def1..4a00e0af9d9 100644
--- a/app/views/projects/diffs/_file_header.html.haml
+++ b/app/views/projects/diffs/_file_header.html.haml
@@ -1,5 +1,7 @@
- if local_assigns.fetch(:show_toggle, true)
- %i.fa.diff-toggle-caret.fa-fw
+ %span.diff-toggle-caret
+ = sprite_icon('chevron-right', css_class: 'chevron-right gl-display-none')
+ = sprite_icon('chevron-down', css_class: 'chevron-down gl-display-none')
- if diff_file.submodule?
%span
diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml
index 566dfe798c6..1f9533ade83 100644
--- a/app/views/projects/diffs/_replaced_image_diff.html.haml
+++ b/app/views/projects/diffs/_replaced_image_diff.html.haml
@@ -14,7 +14,7 @@
.wrap
.frame.deleted
= image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
- %p.image-info.hide
+ %p.image-info.gl-display-none
%span.meta-filesize= number_to_human_size(old_blob.size)
|
%strong W:
@@ -24,7 +24,7 @@
%span.meta-height
.wrap
= render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
- %p.image-info.hide
+ %p.image-info.gl-display-none
%span.meta-filesize= number_to_human_size(blob.size)
|
%strong W:
@@ -33,7 +33,7 @@
%strong H:
%span.meta-height
- .swipe.view.hide
+ .swipe.view.gl-display-none
.swipe-frame
.frame.deleted.old-diff
= image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
@@ -43,7 +43,7 @@
%span.top-handle
%span.bottom-handle
- .onion-skin.view.hide
+ .onion-skin.view.gl-display-none
.onion-skin-frame
.frame.deleted
= image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
@@ -54,7 +54,7 @@
.dragger{ :style => "left: 0px;" }
.opaque
-.view-modes.hide
+.view-modes.gl-display-none
%ul.view-modes-menu
%li.two-up{ data: { mode: 'two-up' } } 2-up
%li.swipe{ data: { mode: 'swipe' } } Swipe
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index 6429cf31bc3..8edaacf7552 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -4,7 +4,7 @@
Showing
%button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown", display: "static" } }<
= pluralize(diff_files.size, "changed file")
- = icon("caret-down", class: "gl-ml-2")
+ = sprite_icon("chevron-down", css_class: "gl-ml-2")
%span.diff-stats-additions-deletions-expanded#diff-stats
with
%strong.cgreen= pluralize(sum_added_lines, 'addition')
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 10dd80501e0..387564f6408 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -7,7 +7,7 @@
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
%button.btn.btn-default.js-settings-toggle{ type: 'button' }= _('Collapse')
- %p= _('Update your project name, topics, description and avatar.')
+ %p= _('Update your project name, topics, description, and avatar.')
.settings-content= render 'projects/settings/general'
%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { qa_selector: 'visibility_features_permissions_content' } }
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index c6d39f5bba0..2936eff45df 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -1,12 +1,13 @@
- @content_class = "limit-container-width" unless fluid_layout
- default_branch_name = @project.default_branch || "master"
-- breadcrumb_title _("Details")
-- page_title _("Details")
+- @skip_current_level_breadcrumb = true
= render partial: 'flash_messages', locals: { project: @project }
= render "home_panel"
+= render "invite_members" if experiment_enabled?(:invite_members_empty_project_version_a) && can_import_members?
+
%h4.gl-mt-0.gl-mb-3
= _('The repository for this project is empty')
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index 67dc07fb785..89c2c826067 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -15,7 +15,7 @@
= sort_options_hash[@sort]
- else
= sort_title_recently_created
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right
%li
- excluded_filters = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id]
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index 24d92e947bc..a92b02701c5 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -25,7 +25,7 @@
= (_("Code coverage statistics for master %{start_date} - %{end_date}") % {start_date: start_date, end_date: end_date})
- download_path = capture do
#{@daily_coverage_options[:download_path]}
- %a.btn.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" }
+ %a.btn.gl-button.btn-sm{ href: "#{download_path}?#{@daily_coverage_options[:base_params].to_query}" }
%small
= _("Download raw data (.csv)")
#js-code-coverage-chart{ data: { graph_endpoint: "#{@daily_coverage_options[:graph_api_path]}?#{@daily_coverage_options[:base_params].to_query}" } }
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index a73e367733b..c7508ef4d47 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -3,6 +3,6 @@
.sub-header-block.bg-gray-light.gl-p-5
.tree-ref-holder.inline.vertical-align-middle
= render 'shared/ref_switcher', destination: 'graphs'
- = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn'
+ = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn gl-button'
.js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref }
diff --git a/app/views/projects/issuable/_show.html.haml b/app/views/projects/issuable/_show.html.haml
index 48920c4e342..8015b205568 100644
--- a/app/views/projects/issuable/_show.html.haml
+++ b/app/views/projects/issuable/_show.html.haml
@@ -3,7 +3,6 @@
- if issuable.relocation_target
- page_canonical_link issuable.relocation_target.present(current_user: current_user).web_url
-= render_if_exists "projects/issues/alert_blocked", issue: issuable, current_user: current_user
= render "projects/issues/alert_moved_from_service_desk", issue: issuable
= render 'shared/issue_type/details_header', issuable: issuable
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 51130ae666c..2fbaa5812c0 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -3,11 +3,6 @@
- @gfm_form = true
-- content_for :note_actions do
- - if can?(current_user, :update_issue, @issue)
- = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "gl-button btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "gl-button btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
-
%section.issuable-discussion.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@issue).to_json,
noteable_data: serialize_issuable(@issue, with_blocking_issues: true),
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index d9ad171a6cc..23510713494 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -1,67 +1,68 @@
-# DANGER: Any changes to this file need to be reflected in issuables_list/components/issuable.vue!
%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue_container', qa_issue_title: issue.title } }
- .issue-box
+ .issuable-info-container
- if @can_bulk_update
.issue-check.hidden
= check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable"
- .issuable-info-container
- .issuable-main-info
- .issue-title.title
- %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" }
- - if issue.confidential?
- %span.has-tooltip{ title: _('Confidential') }
- = confidential_icon(issue)
- = link_to issue.title, issue_path(issue)
- = render_if_exists 'projects/issues/subepic_flag', issue: issue
- - if issue.tasks?
- %span.task-status.d-none.d-sm-inline-block
- &nbsp;
- = issue.task_status
+ .issuable-main-info
+ .issue-title.title
+ %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" }
+ - if issue.confidential?
+ %span.has-tooltip{ title: _('Confidential') }
+ = confidential_icon(issue)
+ = link_to issue.title, issue_path(issue)
+ = render_if_exists 'projects/issues/subepic_flag', issue: issue
+ - if issue.tasks?
+ %span.task-status.d-none.d-sm-inline-block
+ &nbsp;
+ = issue.task_status
- .issuable-info
- %span.issuable-reference
- #{issuable_reference(issue)}
- %span.issuable-authored.d-none.d-sm-inline-block
- &middot;
- opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')}
- by #{link_to_member(@project, issue.author, avatar: false)}
- = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: issue.author}
- - if issue.milestone
- %span.issuable-milestone.d-none.d-sm-inline-block
- &nbsp;
- = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do
- = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom')
- = issue.milestone.title
- - if issue.due_date
- %span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') }
- &nbsp;
- = sprite_icon('calendar')
- = issue.due_date.to_s(:medium)
+ .issuable-info
+ %span.issuable-reference
+ #{issuable_reference(issue)}
+ %span.issuable-authored.d-none.d-sm-inline-block
+ &middot;
+ opened #{time_ago_with_tooltip(issue.created_at, placement: 'bottom')} by
+ - if issue.service_desk_reply_to
+ #{issue.service_desk_reply_to} via
+ #{link_to_member(@project, issue.author, avatar: false)}
+ = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: issue.author
+ - if issue.milestone
+ %span.issuable-milestone.d-none.d-sm-inline-block
+ &nbsp;
+ = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do
+ = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom')
+ = issue.milestone.title
+ - if issue.due_date
+ %span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') }
+ &nbsp;
+ = sprite_icon('calendar')
+ = issue.due_date.to_s(:medium)
- = render_if_exists "projects/issues/issue_weight", issue: issue
- = render_if_exists "projects/issues/health_status", issue: issue
+ = render_if_exists "projects/issues/issue_weight", issue: issue
+ = render_if_exists "projects/issues/health_status", issue: issue
- - if issue.labels.any?
- &nbsp;
- - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label|
- = link_to_label(label, small: true)
+ - if issue.labels.any?
+ &nbsp;
+ - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label|
+ = link_to_label(label, small: true)
- = render "projects/issues/issue_estimate", issue: issue
+ = render "projects/issues/issue_estimate", issue: issue
- .issuable-meta
- %ul.controls
- - if issue.closed? && issue.moved?
- %li.issuable-status
- = _('CLOSED (MOVED)')
- - elsif issue.closed?
- %li.issuable-status
- = _('CLOSED')
- - if issue.assignees.any?
- %li.gl-display-flex
- = render 'shared/issuable/assignees', project: @project, issuable: issue
+ .issuable-meta
+ %ul.controls
+ - if issue.closed? && issue.moved?
+ %li.issuable-status
+ = _('CLOSED (MOVED)')
+ - elsif issue.closed?
+ %li.issuable-status
+ = _('CLOSED')
+ - if issue.assignees.any?
+ %li.gl-display-flex
+ = render 'shared/issuable/assignees', project: @project, issuable: issue
- = render 'shared/issuable_meta_data', issuable: issue
+ = render 'shared/issuable_meta_data', issuable: issue
- .float-right.issuable-updated-at.d-none.d-sm-inline-block
- %span
- = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago') }
+ .float-right.issuable-updated-at.d-none.d-sm-inline-block
+ %span
+ = _('updated %{time_ago}').html_safe % { time_ago: time_ago_with_tooltip(issue.updated_at, placement: 'bottom', html_class: 'issue_update_ago') }
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 34260899d94..008340a3fe7 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -21,8 +21,8 @@
%button.btn.js-create-merge-request.btn-success.btn-inverted{ type: 'button', data: { action: data_action } }
= value
- %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-success.btn-inverted.js-dropdown-toggle.flex-grow-0{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' }, display: 'static' } }
- = icon('caret-down')
+ %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' } }
+ = sprite_icon('chevron-down')
.droplab-dropdown
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-right.gl-show-field-errors{ class: ("create-confidential-merge-request-dropdown-menu" if can_create_confidential_merge_request?), data: { dropdown: true } }
diff --git a/app/views/projects/jobs/_table.html.haml b/app/views/projects/jobs/_table.html.haml
index b08223546f7..b126b452dea 100644
--- a/app/views/projects/jobs/_table.html.haml
+++ b/app/views/projects/jobs/_table.html.haml
@@ -1,8 +1,20 @@
- admin = local_assigns.fetch(:admin, false)
- if builds.blank?
- %div
- .nothing-here-block No jobs to show
+ - if experiment_enabled?(:jobs_empty_state)
+ .row.empty-state
+ .col-12
+ .svg-content.svg-250
+ = image_tag('jobs-empty-state.svg')
+ .col-12
+ .text-content.gl-text-center
+ %h4
+ = 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'), help_page_path('ci/quick_start/README'), class: 'btn gl-button btn-info js-empty-state-button'
+ - else
+ .nothing-here-block= s_('Jobs|No jobs to show')
- else
.table-holder
%table.table.ci-table.builds-page
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index a1960fc99cf..cd062fcf675 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -7,8 +7,8 @@
.nav-controls
- if can?(current_user, :update_build, @project)
- - unless @repository.gitlab_ci_yml
- = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn gl-button btn-info'
+ - if !@repository.gitlab_ci_yml && !experiment_enabled?(:jobs_empty_state)
+ = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn gl-button btn-info js-empty-state-button'
= link_to project_ci_lint_path(@project), class: 'btn gl-button btn-default' do
%span CI lint
diff --git a/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
new file mode 100644
index 00000000000..3a8629b3b6e
--- /dev/null
+++ b/app/views/projects/merge_requests/_close_reopen_draft_report_toggle.html.haml
@@ -0,0 +1,37 @@
+- display_issuable_type = issuable_display_type(@merge_request)
+- button_action_class = @merge_request.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary'
+- button_class = "btn gl-button #{!@merge_request.closed? && 'js-draft-toggle-button'}"
+- toggle_class = "btn gl-button dropdown-toggle"
+
+.float-left.btn-group.gl-ml-3.gl-display-none.gl-display-md-flex
+ = link_to @merge_request.closed? ? reopen_issuable_path(@merge_request) : toggle_draft_merge_request_path(@merge_request), method: :put, class: "#{button_class} #{button_action_class}" do
+ - if @merge_request.closed?
+ = _('Reopen')
+ = display_issuable_type
+ - else
+ = @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft')
+
+ - if !@merge_request.closed? || !issuable_author_is_current_user(@merge_request)
+ = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do
+ %span.gl-sr-only= _('Toggle dropdown')
+ = sprite_icon "angle-down", size: 12
+
+ %ul.dropdown-menu.dropdown-menu-right
+ - if @merge_request.open?
+ %li
+ = link_to close_issuable_path(@merge_request), method: :put do
+ .description
+ %strong.title
+ = _('Close')
+ = display_issuable_type
+
+ - unless issuable_author_is_current_user(@merge_request)
+ - unless @merge_request.closed?
+ %li.divider.droplab-item-ignore
+
+ %li
+ %a{ href: new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) }
+ .description
+ %strong.title= _('Report abuse')
+ %p.text.gl-mb-0
+ = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize }
diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml
deleted file mode 100644
index a831972a823..00000000000
--- a/app/views/projects/merge_requests/_how_to_merge.html.haml
+++ /dev/null
@@ -1,56 +0,0 @@
-#modal_merge_info.modal{ tabindex: '-1' }
- .modal-dialog.modal-lg
- .modal-content
- .modal-header
- %h3.modal-title Check out, review, and merge locally
- %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
- %span{ "aria-hidden": true } &times;
- .modal-body
- %p
- %strong Step 1.
- Fetch and check out the branch for this merge request
- = clipboard_button(target: "pre#merge-info-1", title: _("Copy commands"))
- %pre.dark#merge-info-1
- - if @merge_request.for_fork?
- -# All repo/branch refs have been quoted to allow support for special characters (such as #my-branch)
- :preserve
- git fetch "#{h default_url_to_repo(@merge_request.source_project)}" "#{h @merge_request.source_branch}"
- git checkout -b "#{h @merge_request.source_project_path}-#{h @merge_request.source_branch}" FETCH_HEAD
- - else
- :preserve
- git fetch origin
- git checkout -b "#{h @merge_request.source_branch}" "origin/#{h @merge_request.source_branch}"
- %p
- %strong Step 2.
- Review the changes locally
-
- %p
- %strong Step 3.
- Merge the branch and fix any conflicts that come up
- = clipboard_button(target: "pre#merge-info-3", title: _("Copy commands"))
- %pre.dark#merge-info-3
- - if @merge_request.for_fork?
- :preserve
- git fetch origin
- git checkout "#{h @merge_request.target_branch}"
- git merge --no-ff "#{h @merge_request.source_project_path}-#{h @merge_request.source_branch}"
- - else
- :preserve
- git fetch origin
- git checkout "#{h @merge_request.target_branch}"
- git merge --no-ff "#{h @merge_request.source_branch}"
- %p
- %strong Step 4.
- Push the result of the merge to GitLab
- = clipboard_button(target: "pre#merge-info-4", title: _("Copy commands"))
- %pre.dark#merge-info-4
- :preserve
- git push origin "#{h @merge_request.target_branch}"
- - unless @merge_request.can_be_merged_by?(current_user)
- %p
- Note that pushing to GitLab requires write access to this repository.
- %p
- %strong Tip:
- = succeed '.' do
- You can also checkout merge requests locally by
- = link_to 'following these guidelines', help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: "checkout-merge-requests-locally-through-the-head-ref"), target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index 092055a5f85..4711143c900 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -20,7 +20,7 @@
&middot;
opened #{time_ago_with_tooltip(merge_request.created_at, placement: 'bottom')}
by #{link_to_member(@project, merge_request.author, avatar: false)}
- = render_if_exists 'shared/issuable/gitlab_team_member_badge', {author: merge_request.author}
+ = render_if_exists 'shared/issuable/gitlab_team_member_badge', author: merge_request.author
- if merge_request.milestone
%span.issuable-milestone.d-none.d-sm-inline-block
&nbsp;
@@ -55,7 +55,7 @@
- if merge_request.assignees.any?
%li.gl-display-flex.gl-align-items-center
= render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request
- - if Feature.enabled?(:merge_request_reviewers, @project) && merge_request.reviewers.any?
+ - if Feature.enabled?(:merge_request_reviewers, @project, default_enabled: true) && merge_request.reviewers.any?
%li.gl-display-flex.issuable-reviewers
= render 'shared/issuable/reviewers', project: merge_request.project, issuable: merge_request
= render 'projects/merge_requests/approvals_count', merge_request: merge_request
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index cd4ffa8602e..1691a304e8b 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -2,8 +2,9 @@
- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
- can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request)
- state_human_name, state_icon_name = state_name_with_icon(@merge_request)
+- are_close_and_open_buttons_hidden = merge_request_button_hidden?(@merge_request, true) && merge_request_button_hidden?(@merge_request, false)
-- if @merge_request.closed_without_fork?
+- if @merge_request.closed_or_merged_without_fork?
.gl-alert.gl-alert-danger.gl-mb-5
= sprite_icon('error', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
.gl-alert-body
@@ -18,33 +19,35 @@
.issuable-meta
#js-issuable-header-warnings
- = issuable_meta(@merge_request, @project, "Merge request")
+ = 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: "#" }
= sprite_icon('chevron-double-lg-left')
.detail-page-header-actions.js-issuable-actions
- .clearfix.issue-btn-group.dropdown
- %button.btn.btn-default.float-left.d-md-none{ type: "button", data: { toggle: "dropdown" } }
+ .clearfix.dropdown
+ %button.gl-button.btn.btn-default.float-left.gl-display-md-none.gl-w-full{ type: "button", data: { toggle: "dropdown" } }
Options
- = icon('caret-down')
+ = sprite_icon('chevron-down', css_class: 'gl-text-gray-500')
.dropdown-menu.dropdown-menu-right
%ul
- if can_update_merge_request
%li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request)
- - if can_update_merge_request
- - unless @merge_request.closed?
+ - if @merge_request.opened?
%li
- = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_issuable_path(@merge_request), method: :put, class: "js-draft-toggle-button"
+ = link_to @merge_request.work_in_progress? ? _('Mark as ready') : _('Mark as draft'), toggle_draft_merge_request_path(@merge_request), method: :put, class: "js-draft-toggle-button"
%li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] }
= link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
- if can_reopen_merge_request
%li{ class: merge_request_button_visibility(@merge_request, false) }
- = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
+ = link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, title: 'Reopen merge request'
- unless @merge_request.merged? || current_user == @merge_request.author
%li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
= link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-md-block btn gl-button btn-grouped js-issuable-edit qa-edit-button"
- = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_reopen_merge_request
+ - 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-display-md-block gl-button btn btn-warning-secondary float-right gl-ml-3', title: _('Report abuse')
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 9736071b03f..123affeb5d6 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -1,4 +1,4 @@
-= javascript_tag nonce: true do
+= javascript_tag do
:plain
window.gl = window.gl || {};
window.gl.mrWidgetData = #{serialize_issuable(@merge_request, serializer: 'widget', issues_links: true)}
diff --git a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
index e6205f24ae6..cb1cb41eb71 100644
--- a/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
+++ b/app/views/projects/merge_requests/conflicts/_commit_stats.html.haml
@@ -1,16 +1,11 @@
.content-block.oneline-block.files-changed{ "v-if" => "!isLoading && !hasError" }
.inline-parallel-buttons{ "v-if" => "showDiffViewTypeSwitcher" }
.btn-group
- %button.btn{ ":class" => "{'active': !isParallel}", "@click" => "handleViewTypeChange('inline')" }
- Inline
- %button.btn{ ":class" => "{'active': isParallel}", "@click" => "handleViewTypeChange('parallel')" }
- Side-by-side
+ %button.btn.gl-button{ ":class" => "{'active': !isParallel}", "@click" => "handleViewTypeChange('inline')" }
+ = _('Inline')
+ %button.btn.gl-button{ ":class" => "{'active': isParallel}", "@click" => "handleViewTypeChange('parallel')" }
+ = _('Side-by-side')
.js-toggle-container
.commit-stat-summary
- Showing
- %strong.cred {{conflictsCountText}}
- between
- %strong.ref-name {{conflictsData.sourceBranch}}
- and
- %strong.ref-name {{conflictsData.targetBranch}}
+ = _('Showing %{conflict_start}%{conflicts_text}%{strong_end} between %{ref_start}%{source_branch}%{strong_end} and %{ref_start}%{target_branch}%{strong_end}').html_safe % { conflict_start: '<strong class="cred">'.html_safe, ref_start: '<strong class="ref-name">'.html_safe, strong_end: '</strong>'.html_safe, conflicts_text: '{{conflictsCountText}}', source_branch: '{{conflictsData.sourceBranch}}', target_branch: '{{conflictsData.targetBranch}}' }
diff --git a/app/views/projects/merge_requests/conflicts/_file_actions.html.haml b/app/views/projects/merge_requests/conflicts/_file_actions.html.haml
index 0839880713f..220ddf1bad3 100644
--- a/app/views/projects/merge_requests/conflicts/_file_actions.html.haml
+++ b/app/views/projects/merge_requests/conflicts/_file_actions.html.haml
@@ -1,12 +1,12 @@
-.file-actions
- .btn-group{ "v-if" => "file.type === 'text'" }
- %button.btn{ ":class" => "{ 'active': file.resolveMode == 'interactive' }",
+.file-actions.d-flex.align-items-center.gl-ml-auto.gl-align-self-start
+ .btn-group.gl-mr-3{ "v-if" => "file.type === 'text'" }
+ %button.btn.gl-button{ ":class" => "{ 'active': file.resolveMode == 'interactive' }",
'@click' => "onClickResolveModeButton(file, 'interactive')",
type: 'button' }
- Interactive mode
- %button.btn{ ':class' => "{ 'active': file.resolveMode == 'edit' }",
+ = _('Interactive mode')
+ %button.btn.gl-button{ ':class' => "{ 'active': file.resolveMode == 'edit' }",
'@click' => "onClickResolveModeButton(file, 'edit')",
type: 'button' }
- Edit inline
- %a.btn.view-file{ ":href" => "file.blobPath" }
- View file @{{conflictsData.shortCommitSha}}
+ = _('Edit inline')
+ %a.btn.gl-button.view-file{ ":href" => "file.blobPath" }
+ = _('View file @%{commit_sha}') % { commit_sha: '{{conflictsData.shortCommitSha}}' }
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
index 94c262d300e..15655e2b162 100644
--- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -18,7 +18,7 @@
.offset-md-4.col-md-8
.row
.col-6
- %button.btn.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" }
+ %button.btn.gl-button.btn-success.js-submit-button{ type: "button", "@click" => "commit()", ":disabled" => "!readyToCommit" }
%span {{commitButtonText}}
.col-6.text-right
= link_to "Cancel", project_merge_request_path(@merge_request.project, @merge_request), class: "gl-button btn btn-cancel"
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index decdbce3fa7..827df540629 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -20,9 +20,10 @@
.files-wrapper{ "v-if" => "!isLoading && !hasError" }
.files
.diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" }
- .js-file-title.file-title
- %i.fa.fa-fw{ ":class" => "file.iconClass" }
- %strong {{file.filePath}}
+ .js-file-title.file-title.file-title-flex-parent.cursor-default
+ .file-header-content
+ %file-icon{ ':file-name': 'file.filePath', ':size': '18', 'css-classes': 'gl-mr-2' }
+ %strong.file-title-name {{file.filePath}}
= render partial: 'projects/merge_requests/conflicts/file_actions'
.diff-content.diff-wrap-lines
.file-content{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 6b506c38795..c70fc624dde 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -16,9 +16,6 @@
.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"
- - if @merge_request.source_branch_exists?
- = render "projects/merge_requests/how_to_merge"
-
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
= render "projects/merge_requests/mr_box"
.merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') }
@@ -58,6 +55,8 @@
= render "projects/merge_requests/description"
= render "projects/merge_requests/widget"
= render "projects/merge_requests/awards_block"
+ - if mr_action === "show"
+ - add_page_startup_api_call discussions_path(@merge_request)
#js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json,
noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'),
noteable_type: 'MergeRequest',
diff --git a/app/views/projects/merge_requests/widget/open/_error.html.haml b/app/views/projects/merge_requests/widget/open/_error.html.haml
index bbdc053609f..31efa64c672 100644
--- a/app/views/projects/merge_requests/widget/open/_error.html.haml
+++ b/app/views/projects/merge_requests/widget/open/_error.html.haml
@@ -1,5 +1,5 @@
%h4
- = icon('exclamation-triangle')
+ = sprite_icon('warning-solid')
This merge request failed to be merged automatically
%p
diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml
index 4366676bd45..30ba22ba53c 100644
--- a/app/views/projects/network/show.html.haml
+++ b/app/views/projects/network/show.html.haml
@@ -8,7 +8,7 @@
= text_field_tag :extended_sha1, @options[:extended_sha1], placeholder: _("Git revision"), class: 'search-input form-control input-mx-250 search-sha'
= button_tag class: 'btn btn-success' do
= sprite_icon('search')
- .inline.prepend-left-20
+ .inline.gl-ml-5
.form-check.light
= check_box_tag :filter_ref, 1, @options[:filter_ref], class: 'form-check-input'
= label_tag :filter_ref, class: 'form-check-label' do
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index f2972a9617b..a407aa9ac13 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -8,10 +8,9 @@
.project-edit-errors
= render 'projects/errors'
- - if experiment_enabled?(:new_create_project_ui)
- .js-experiment-new-project-creation{ data: { is_ci_cd_available: ci_cd_projects_available?, has_errors: @project.errors.any? } }
+ .js-experiment-new-project-creation{ data: { is_ci_cd_available: (ci_cd_projects_available? if Gitlab.ee?), has_errors: @project.errors.any? } }
- .row{ 'v-cloak': experiment_enabled?(:new_create_project_ui) }
+ .row{ 'v-cloak': true }
.col-lg-3.profile-settings-sidebar
%h4.gl-mt-0
= _('New project')
diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml
index 65c4232b240..d7853c1b466 100644
--- a/app/views/projects/no_repo.html.haml
+++ b/app/views/projects/no_repo.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title _("Details")
-- page_title _("Details")
+- page_title _('No repository')
+- @skip_current_level_breadcrumb = true
%h2.gl-display-flex
.gl-display-flex.gl-align-items-center.gl-justify-content-center
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 8955b568741..b41c3f4fc27 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -26,7 +26,7 @@
= render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project
.tab-content
- #js-tab-pipeline.tab-pane.gl-absolute.gl-left-0.gl-w-full
+ #js-tab-pipeline.tab-pane.gl-w-full
#js-pipeline-graph-vue
#js-tab-builds.tab-pane
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 55f1b9098c3..f3360e150ad 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,7 +1,10 @@
- page_title _('CI / CD Analytics')
-#js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts),
- times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times },
- last_week_chart: { labels: @charts[:week].labels, totals: @charts[:week].total, success: @charts[:week].success },
- last_month_chart: { labels: @charts[:month].labels, totals: @charts[:month].total, success: @charts[:month].success },
- last_year_chart: { labels: @charts[:year].labels, totals: @charts[:year].total, success: @charts[:year].success } } }
+- if Feature.enabled?(:graphql_pipeline_analytics)
+ #js-project-pipelines-charts-app{ data: { project_path: @project.full_path } }
+- else
+ #js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts),
+ times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times },
+ last_week_chart: { labels: @charts[:week].labels, totals: @charts[:week].total, success: @charts[:week].success },
+ last_month_chart: { labels: @charts[:month].labels, totals: @charts[:month].total, success: @charts[:month].success },
+ last_year_chart: { labels: @charts[:year].labels, totals: @charts[:year].total, success: @charts[:year].success } } }
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 6aa1a564499..64ae4ff8daf 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -8,7 +8,7 @@
project_id: @project.id,
params: params.to_json,
"help-page-path" => help_page_path('ci/quick_start/README'),
- "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'),
+ "auto-devops-help-path" => help_page_path('topics/autodevops/index.md'),
"pipeline-schedule-url" => pipeline_schedules_path(@project),
"empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'),
"error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'),
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index bc8e6a6d9cc..7d5cef2015d 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -10,10 +10,12 @@
#js-new-pipeline{ data: { project_id: @project.id,
pipelines_path: project_pipelines_path(@project),
config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project),
+ default_branch: @project.default_branch,
ref_param: params[:ref] || @project.default_branch,
var_param: params[:var].to_json,
file_param: params[:file_var].to_json,
- ref_names: @project.repository.ref_names.to_json.html_safe,
+ branch_refs: @project.repository.branch_names.to_json.html_safe,
+ tag_refs: @project.repository.tag_names.to_json.html_safe,
settings_link: project_settings_ci_cd_path(@project),
max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } }
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 0b07fe9921e..847b96cbd0e 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -23,4 +23,4 @@
= render "projects/pipelines/with_tabs", pipeline: @pipeline, pipeline_has_errors: pipeline_has_errors
-.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } }
+.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid } }
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 9ac1fda169f..b53fbc97c02 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -17,6 +17,7 @@
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
-
+ "project_path": @project.full_path,
+ "gid_prefix": container_repository_gid_prefix,
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml
index c6fae2cc7a1..a4d4a1bb2dd 100644
--- a/app/views/projects/registry/settings/_index.haml
+++ b/app/views/projects/registry/settings/_index.haml
@@ -5,4 +5,5 @@
older_than_options: older_than_options.to_json,
is_admin: current_user&.admin.to_s,
admin_settings_path: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'),
- enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s} }
+ enable_historic_entries: container_expiration_policies_historic_entry_enabled?(@project).to_s,
+ tags_regex_help_page_path: help_page_path('user/packages/container_registry/index', anchor: 'regex-pattern-examples') } }
diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml
index c567b453bf2..4093f0a0719 100644
--- a/app/views/projects/runners/_shared_runners.html.haml
+++ b/app/views/projects/runners/_shared_runners.html.haml
@@ -1,16 +1,22 @@
+- isVueifySharedRunnersToggleEnabled = Feature.enabled?(:vueify_shared_runners_toggle, @project)
+
= render layout: 'shared/runners/shared_runners_description' do
- %hr
- - if @project.group&.shared_runners_setting == 'disabled_and_unoverridable'
- %h5.gl-text-red-500
- = _('Shared runners disabled on group level')
- - else
- - if @project.shared_runners_enabled?
- = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do
- = _('Disable shared runners')
+ - if !isVueifySharedRunnersToggleEnabled
+ %hr
+ - if @project.group&.shared_runners_setting == 'disabled_and_unoverridable'
+ %h5.gl-text-red-500
+ = _('Shared runners disabled on group level')
- else
- = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
- = _('Enable shared runners')
- &nbsp; for this project
+ - if @project.shared_runners_enabled?
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-close', method: :post do
+ = _('Disable shared runners')
+ - else
+ = link_to toggle_shared_runners_project_runners_path(@project), class: 'btn btn-success', method: :post do
+ = _('Enable shared runners')
+ &nbsp; for this project
+
+- if isVueifySharedRunnersToggleEnabled
+ #toggle-shared-runners-form{ data: toggle_shared_runners_settings_data(@project) }
- if @shared_runners_count == 0
= _('This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area.')
diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
index 9d81fda68cb..549ca36cb6a 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,4 +1,4 @@
-- pretty_name = html_escape(@project&.full_name) || html_escape_once(_('&lt;project name&gt;')).html_safe
+- 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:")
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 86486d95eb7..67c43bd2f33 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -1,4 +1,4 @@
-- pretty_name = @project&.full_name || _('&lt;project name&gt;')
+- pretty_name = @project&.full_name ? html_escape(@project&.full_name) : '<' + _('project name') + '>'
- run_actions_text = html_escape_once(s_("ProjectService|Perform common operations on GitLab project: %{project_name}") % { project_name: pretty_name })
.info-well
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index f6ecb923100..0bef82ee325 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -66,11 +66,11 @@
%section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) }
.settings-header
%h4
- = _("Cleanup policy for tags")
+ = _("Clean up image tags")
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _("Save space and find tags in the Container Registry more easily. Enable the cleanup policy to remove stale tags and keep only the ones you need.")
+ = _("Save space and find images in the Container Registry. Remove unneeded tags and keep only the ones you want.")
= link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer')
.settings-content
= render 'projects/registry/settings/index'
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index e5d34ff0fc9..73722a5a789 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -2,7 +2,7 @@
- page_title _('Operations Settings')
- breadcrumb_title _('Operations Settings')
-= render 'projects/settings/operations/alert_management', alerts_service: alerts_service, prometheus_service: prometheus_service
+= render 'projects/settings/operations/alert_management'
= render 'projects/settings/operations/incidents'
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/prometheus', service: prometheus_service if Feature.enabled?(:settings_operations_prometheus_service)
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index f7c51e9ada9..5b9f868a71a 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title _("Details")
- @content_class = "limit-container-width" unless fluid_layout
+- @skip_current_level_breadcrumb = true
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index 7679e0714fe..9d4e5d629f4 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -27,9 +27,6 @@
= sprite_icon("rocket", size: 12)
= _("Release")
= link_to release.name, project_releases_path(@project, anchor: release.tag), class: 'gl-text-blue-600!'
- - if release.description.present?
- .md.gl-mt-3
- = markdown_field(release, :description)
.row-fixed-content.controls.flex-row
- if tag.has_signature?
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index e0def8cf155..2fe5c5888f5 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -16,7 +16,7 @@
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown'} }
%span.light
= tags_sort_options_hash[@sort]
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
= s_('TagsPage|Sort by')
diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml
index fe42394d919..73b2a92dcc0 100644
--- a/app/views/projects/tags/new.html.haml
+++ b/app/views/projects/tags/new.html.haml
@@ -24,7 +24,7 @@
= hidden_field_tag :ref, default_ref
= button_tag type: 'button', title: default_ref, class: 'dropdown-menu-toggle wide js-branch-select monospace', required: true, data: { toggle: 'dropdown', selected: default_ref, field_name: 'ref' } do
.text-left.dropdown-toggle-text= default_ref
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
= render 'shared/ref_dropdown', dropdown_class: 'wide'
.form-text.text-muted
= s_('TagsPage|Existing branch name, tag, or commit SHA')
diff --git a/app/views/projects/terraform/index.html.haml b/app/views/projects/terraform/index.html.haml
index 136e7ded224..21a4fe5eae6 100644
--- a/app/views/projects/terraform/index.html.haml
+++ b/app/views/projects/terraform/index.html.haml
@@ -1,4 +1,6 @@
+- add_page_specific_style 'page_bundles/ci_status'
+
- breadcrumb_title _('Terraform')
- page_title _('Terraform')
-#js-terraform-list{ data: js_terraform_list_data(@project) }
+#js-terraform-list{ data: js_terraform_list_data(current_user, @project) }
diff --git a/app/views/projects/tree/_truncated_notice_tree_row.html.haml b/app/views/projects/tree/_truncated_notice_tree_row.html.haml
index 693b641888b..a03e0a549ee 100644
--- a/app/views/projects/tree/_truncated_notice_tree_row.html.haml
+++ b/app/views/projects/tree/_truncated_notice_tree_row.html.haml
@@ -1,6 +1,6 @@
%tr.tree-truncated-warning
%td{ colspan: '3' }
- = icon('exclamation-triangle fw')
+ = sprite_icon('warning-solid')
%span
Too many items to show. To preserve performance only
%strong #{number_with_delimiter(limit)} of #{number_with_delimiter(total)}
diff --git a/app/views/registrations/experience_levels/show.html.haml b/app/views/registrations/experience_levels/show.html.haml
index 24b87790e18..f878245a48c 100644
--- a/app/views/registrations/experience_levels/show.html.haml
+++ b/app/views/registrations/experience_levels/show.html.haml
@@ -15,8 +15,8 @@
= image_tag 'novice.svg', width: '78', height: '78', alt: ''
%div
%p.gl-font-lg.gl-font-weight-bold.gl-mb-2= _('Novice')
- %p= _('I’m not very familiar with the basics of project management and DevOps.')
- = link_to _('Show me everything'), users_sign_up_experience_level_path(experience_level: :novice, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link'
+ %p= _('I’m not familiar with the basics of DevOps.')
+ = link_to _('Show me the basics'), users_sign_up_experience_level_path(experience_level: :novice, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link'
.card
.card-body.gl-display-flex.gl-py-8.gl-pr-5.gl-pl-7
@@ -24,5 +24,5 @@
= image_tag 'experienced.svg', width: '78', height: '78', alt: ''
%div
%p.gl-font-lg.gl-font-weight-bold.gl-mb-2= _('Experienced')
- %p= _('I’m familiar with the basics of project management and DevOps.')
- = link_to _('Show me more advanced stuff'), users_sign_up_experience_level_path(experience_level: :experienced, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link'
+ %p= _('I’m familiar with the basics of DevOps.')
+ = link_to _('Show me advanced features'), users_sign_up_experience_level_path(experience_level: :experienced, namespace_path: params[:namespace_path]), method: :patch, class: 'stretched-link'
diff --git a/app/views/registrations/welcome/show.html.haml b/app/views/registrations/welcome/show.html.haml
index 278c0ff7739..68de80f26f6 100644
--- a/app/views/registrations/welcome/show.html.haml
+++ b/app/views/registrations/welcome/show.html.haml
@@ -14,12 +14,20 @@
.row
.form-group.col-sm-12
= f.label :role, _('Role'), class: 'label-bold'
- = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control', autofocus: true
- .form-text.gl-text-gray-500.gl-mt-3= _('This will help us personalize your onboarding experience.')
+ = 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") }
+ = 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'
+ - else
+ .row
+ .form-group.col-sm-12
+ .form-text.gl-text-gray-500.gl-mt-0.gl-line-height-normal.gl-px-1= _('This will help us personalize your onboarding experience.')
= render_if_exists "registrations/welcome/setup_for_company", 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-register gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' }
+ = f.submit _('Get started!'), class: 'btn-success gl-button btn btn-block gl-mb-0 gl-p-3', data: { qa_selector: 'get_started_button' }
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index 964a2a2772a..e9c6b581c90 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -2,21 +2,13 @@
= hidden_field_tag :group_id, params[:group_id]
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
+- project_attributes = @project&.attributes&.slice('id', 'namespace_id', 'name')&.merge(name_with_namespace: @project&.name_with_namespace)
+
.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } }
%label.d-block{ for: "dashboard_search_group" }
= _("Group")
- %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-group-data": @group.to_json } }
-.dropdown.form-group.mb-lg-0.mx-lg-1{ data: { testid: "project-filter" } }
+ %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": @group.to_json } }
+.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "project-filter" } }
%label.d-block{ for: "dashboard_search_project" }
= _("Project")
- %button.dropdown-menu-toggle.gl-display-inline-flex.js-search-project-dropdown.gl-mt-0{ type: "button", id: "dashboard_search_project", data: { toggle: "dropdown" } }
- %span.dropdown-toggle-text.gl-flex-grow-1.str-truncated-100
- = @project&.full_name || _("Any")
- - if @project.present?
- = link_to sprite_icon("clear"), url_for(safe_params.except(:project_id)), class: 'search-clear js-search-clear has-tooltip', title: _('Clear')
- = icon("chevron-down")
- .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right
- = dropdown_title(_("Filter results by project"))
- = dropdown_filter(_("Search projects"))
- = dropdown_content
- = dropdown_loading
+ %input#js-search-project-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": project_attributes.to_json } }
diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml
index 80973c2b273..a9eee1dd2d6 100644
--- a/app/views/search/_form.html.haml
+++ b/app/views/search/_form.html.haml
@@ -7,9 +7,9 @@
.search-field-holder.form-group.mr-lg-1.mb-lg-0
%label{ for: "dashboard_search" }
= _("What are you searching for?")
- .position-relative
- = search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false
- = sprite_icon('search', css_class: 'search-icon')
+ .gl-search-box-by-type
+ = search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "gl-form-input form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false
+ = sprite_icon('search', css_class: 'gl-search-box-by-type-search-icon gl-icon')
%button.search-clear.js-search-clear{ class: [("hidden" if params[:search].blank?), "has-tooltip"], type: "button", tabindex: "-1", title: _('Clear') }
= sprite_icon('clear')
%span.sr-only
@@ -17,4 +17,4 @@
- unless params[:snippets].eql? 'true'
= render 'filter'
.d-flex-center.flex-column.flex-lg-row
- = button_tag _("Search"), class: "gl-button btn btn-success btn-search form-control mt-lg-0 ml-lg-1 align-self-end"
+ = button_tag _("Search"), class: "gl-button btn btn-success btn-search mt-lg-0 ml-lg-1 align-self-end"
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index 855112bdba2..80d0253d273 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,37 +1,20 @@
+- search_bar_classes = 'search-sidebar gl-display-flex gl-flex-direction-column gl-mr-4'
+
- if @search_objects.to_a.empty?
.gl-display-md-flex
- if %w(issues merge_requests).include?(@scope)
- #js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ }
- .gl-w-full
+ #js-search-sidebar{ class: search_bar_classes }
+ .gl-w-full.gl-flex-fill-1.gl-overflow-x-hidden
= render partial: "search/results/empty"
= render_if_exists 'shared/promotions/promote_advanced_search'
- else
- .search-results-status
- .row-content-block.gl-display-flex
- .gl-display-md-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1
- - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount)
- = search_entries_info(@search_objects, @scope, @search_term)
- - unless @show_snippets
- - if @project
- - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1')
- - if @scope == 'blobs'
- = s_("SearchCodeResults|in")
- .mx-md-1
- = render partial: "shared/ref_switcher", locals: { ref: repository_ref(@project), form_path: request.fullpath, field_name: 'repository_ref' }
- = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
- - else
- = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
- - elsif @group
- - link_to_group = link_to(@group.name, @group, class: 'ml-md-1')
- = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
- .gl-display-md-flex.gl-flex-direction-column
- = render partial: 'search/sort_dropdown'
+ = render partial: 'search/results_status', locals: { search_service: @search_service }
= render_if_exists 'shared/promotions/promote_advanced_search'
.results.gl-display-md-flex.gl-mt-3
- if %w(issues merge_requests).include?(@scope)
- #js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ }
- .gl-w-full
+ #js-search-sidebar{ class: search_bar_classes }
+ .gl-w-full.gl-flex-fill-1.gl-overflow-x-hidden
- if @scope == 'commits'
%ul.content-list.commit-list
= render partial: "search/results/commit", collection: @search_objects
diff --git a/app/views/search/_results_status.html.haml b/app/views/search/_results_status.html.haml
new file mode 100644
index 00000000000..e55f225b162
--- /dev/null
+++ b/app/views/search/_results_status.html.haml
@@ -0,0 +1,25 @@
+- search_service = local_assigns.fetch(:search_service)
+
+- return unless search_service.show_results_status?
+
+.search-results-status
+ .row-content-block.gl-display-flex
+ .gl-display-md-flex.gl-text-left.gl-align-items-center.gl-flex-grow-1
+ - unless search_service.without_count?
+ = search_entries_info(search_service.search_objects, search_service.scope, params[:search])
+ - unless search_service.show_snippets?
+ - if search_service.project
+ - link_to_project = link_to(search_service.project.full_name, search_service.project, class: 'ml-md-1')
+ - if search_service.scope == 'blobs'
+ = _("in")
+ .mx-md-1
+ = render partial: "shared/ref_switcher", locals: { ref: repository_ref(search_service.project), form_path: request.fullpath, field_name: 'repository_ref' }
+ = s_('SearchCodeResults|of %{link_to_project}').html_safe % { link_to_project: link_to_project }
+ - else
+ = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
+ - elsif search_service.group
+ - link_to_group = link_to(search_service.group.name, search_service.group, class: 'ml-md-1')
+ = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
+ - if search_service.show_sort_dropdown?
+ .gl-display-md-flex.gl-flex-direction-column
+ = render partial: 'search/sort_dropdown'
diff --git a/app/views/search/_sort_dropdown.html.haml b/app/views/search/_sort_dropdown.html.haml
index 085e2f348f7..4ae6513d395 100644
--- a/app/views/search/_sort_dropdown.html.haml
+++ b/app/views/search/_sort_dropdown.html.haml
@@ -1,5 +1,3 @@
-- return unless ['issues', 'merge_requests'].include?(@scope)
-
- sort_value = @sort
- sort_title = search_sort_option_title(sort_value)
@@ -8,7 +6,7 @@
.btn-group{ role: 'group' }
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
= sort_title
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= render_if_exists('search/sort_by_relevancy', sort_title: sort_title)
diff --git a/app/views/shared/_alert_info.html.haml b/app/views/shared/_alert_info.html.haml
new file mode 100644
index 00000000000..e47c100909a
--- /dev/null
+++ b/app/views/shared/_alert_info.html.haml
@@ -0,0 +1,6 @@
+.gl-alert.gl-alert-info
+ = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
+ %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', css_class: 'gl-icon')
+ .gl-alert-body
+ = body
diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml
index caf2bdce899..e3f2e1aa436 100644
--- a/app/views/shared/_choose_avatar_button.html.haml
+++ b/app/views/shared/_choose_avatar_button.html.haml
@@ -1 +1 @@
-= render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("The maximum file size allowed is 200KB.")
+= render 'shared/file_picker_button', f: f, field: :avatar, help_text: _("Max file size is 200 KB.")
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 9ec8d3c18cd..fd52f7f40d2 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -1,24 +1,22 @@
-- project = project || @project
-
.git-clone-holder.js-git-clone-holder.input-group
.input-group-prepend
- if allowed_protocols_present?
.input-group-text.clone-dropdown-btn.btn
%span.js-clone-dropdown-label
- = enabled_project_button(project, enabled_protocol)
+ = enabled_protocol_button(container, enabled_protocol)
- else
%a#clone-dropdown.input-group-text.btn.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
%span.js-clone-dropdown-label
= default_clone_protocol.upcase
- = icon('caret-down')
+ = sprite_icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-selectable.clone-options-dropdown
%li
- = ssh_clone_button(project)
+ = ssh_clone_button(container)
%li
- = http_clone_button(project)
- = render_if_exists 'shared/kerberos_clone_button', project: project
+ = http_clone_button(container)
+ = render_if_exists 'shared/kerberos_clone_button', container: container
- = text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Project clone URL') }
+ = text_field_tag :clone_url, default_url_to_repo(container), class: "js-select-on-focus form-control", readonly: true, aria: { label: _('Repository clone URL') }
.input-group-append
- = clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
+ = clipboard_button(target: '#clone_url', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard")
diff --git a/app/views/shared/_file_picker_button.html.haml b/app/views/shared/_file_picker_button.html.haml
index 7c9a3bd3d31..8c10e4958b9 100644
--- a/app/views/shared/_file_picker_button.html.haml
+++ b/app/views/shared/_file_picker_button.html.haml
@@ -1,5 +1,7 @@
+- classes = local_assigns.fetch(:classes, '')
+
%span.js-filepicker
- %button.btn.js-filepicker-button{ type: 'button' }= _("Choose file…")
+ %button.btn.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?
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index ca603eed703..c3fac5cd464 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -47,11 +47,3 @@
= f.label :id, class: 'label-bold' do
= _("Group ID")
= f.text_field :id, class: 'form-control', readonly: true
-
-.row
- .form-group.group-description-holder.col-sm-8
- = f.label :description, class: 'label-bold' do
- = _("Group description")
- %span (optional)
- = f.text_area :description, maxlength: 250,
- class: 'form-control js-gfm-input', rows: 4
diff --git a/app/views/shared/_group_form_description.html.haml b/app/views/shared/_group_form_description.html.haml
new file mode 100644
index 00000000000..9a895cee884
--- /dev/null
+++ b/app/views/shared/_group_form_description.html.haml
@@ -0,0 +1,5 @@
+.row
+ .form-group.group-description-holder.col-sm-8
+ = f.label :description, _('Group description (optional)'), class: 'label-bold'
+ = f.text_area :description, maxlength: 250,
+ class: 'form-control js-gfm-input', rows: 4
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index 0f38d0e3b39..57575f89803 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -1,7 +1,6 @@
- if @issues.to_a.any?
- .card.card-without-border
- %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } }
- = render partial: 'projects/issues/issue', collection: @issues
+ %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } }
+ = render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else
= render 'shared/empty_states/issues'
diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml
index c7c36d79fa0..0976defea1b 100644
--- a/app/views/shared/_md_preview.html.haml
+++ b/app/views/shared/_md_preview.html.haml
@@ -28,7 +28,7 @@
- if referenced_users
.referenced-users.hide
%span
- = icon("exclamation-triangle")
+ = sprite_icon('warning-solid')
You are about to add
%strong
%span.js-referenced-users-count 0
diff --git a/app/views/shared/_merge_requests.html.haml b/app/views/shared/_merge_requests.html.haml
index d280df8b370..dc8efa3e734 100644
--- a/app/views/shared/_merge_requests.html.haml
+++ b/app/views/shared/_merge_requests.html.haml
@@ -1,7 +1,6 @@
- if @merge_requests.to_a.any?
- .card.card-without-border
- %ul.content-list.mr-list.issuable-list
- = render partial: 'projects/merge_requests/merge_request', collection: @merge_requests
+ %ul.content-list.mr-list.issuable-list
+ = render partial: 'projects/merge_requests/merge_request', collection: @merge_requests
= paginate @merge_requests, theme: "gitlab"
diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml
index 06da990e071..29c01343358 100644
--- a/app/views/shared/_milestones_sort_dropdown.html.haml
+++ b/app/views/shared/_milestones_sort_dropdown.html.haml
@@ -5,7 +5,7 @@
= milestone_sort_options_hash[@sort]
- else
= sort_title_due_date_soon
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort
%li
= link_to page_filter_path(sort: sort_value_due_date_soon) do
diff --git a/app/views/shared/_no_password.html.haml b/app/views/shared/_no_password.html.haml
index 76ae63ca5e8..9c1e5a49b44 100644
--- a/app/views/shared/_no_password.html.haml
+++ b/app/views/shared/_no_password.html.haml
@@ -5,7 +5,7 @@
= sprite_icon('close', size: 16, css_class: 'gl-icon')
.gl-alert-body
- translation_params = { protocol: gitlab_config.protocol.upcase, set_password_link: link_to_set_password }
- - set_password_message = _("You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account") % translation_params
+ - set_password_message = _("You won't be able to pull or push repositories via %{protocol} until you %{set_password_link} on your account") % translation_params
= set_password_message.html_safe
.gl-alert-actions
= link_to _('Remind later'), '#', class: 'hide-no-password-message btn gl-alert-action btn-info btn-md gl-button'
diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml
index a083a772233..0a7fa2a3c1e 100644
--- a/app/views/shared/_no_ssh.html.haml
+++ b/app/views/shared/_no_ssh.html.haml
@@ -4,7 +4,7 @@
%button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': _('Dismiss') }
= sprite_icon('close', css_class: 'gl-icon s16')
.gl-alert-body
- = s_("MissingSSHKeyWarningLink|You won't be able to pull or push project code via SSH until you add an SSH key to your profile").html_safe
+ = s_("MissingSSHKeyWarningLink|You won't be able to pull or push repositories via SSH until you add an SSH key to your profile")
.gl-alert-actions
= link_to s_('MissingSSHKeyWarningLink|Add SSH key'), profile_keys_path, class: "btn gl-alert-action btn-warning btn-md new-gl-button"
= link_to s_("MissingSSHKeyWarningLink|Don't show again"), profile_path(user: {hide_no_ssh_key: true}), method: :put, role: 'button', class: 'btn gl-alert-action btn-md btn-warning gl-button btn-warning-secondary'
diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml
index 647421a8fbe..194e0eb57f2 100644
--- a/app/views/shared/_service_settings.html.haml
+++ b/app/views/shared/_service_settings.html.haml
@@ -9,5 +9,5 @@
.service-settings
- if @default_integration
- .js-vue-default-integration-settings{ data: integration_form_data(@default_integration) }
- .js-vue-integration-settings{ data: integration_form_data(integration) }
+ .js-vue-default-integration-settings{ data: integration_form_data(@default_integration, group: @group) }
+ .js-vue-integration-settings{ data: integration_form_data(integration, group: @group) }
diff --git a/app/views/shared/_web_ide_button.html.haml b/app/views/shared/_web_ide_button.html.haml
index 75f5b8647f2..f9c6afcbc32 100644
--- a/app/views/shared/_web_ide_button.html.haml
+++ b/app/views/shared/_web_ide_button.html.haml
@@ -1,8 +1,8 @@
- type = blob ? 'blob' : 'tree'
-.d-inline-block{ data: { options: web_ide_button_data(blob: blob).to_json }, id: "js-#{type}-web-ide-link" }
+.d-inline-block{ data: { options: web_ide_button_data({ blob: blob }).to_json }, id: "js-#{type}-web-ide-link" }
-- if show_edit_button?
+- if show_edit_button?({ blob: blob })
= render 'shared/confirm_fork_modal', fork_path: fork_and_edit_path(@project, @ref, @path), type: 'edit'
- if show_web_ide_button?
= render 'shared/confirm_fork_modal', fork_path: ide_fork_and_edit_path(@project, @ref, @path), type: 'webide'
diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
index 255ec9995db..50daa400e6c 100644
--- a/app/views/shared/access_tokens/_table.html.haml
+++ b/app/views/shared/access_tokens/_table.html.haml
@@ -42,7 +42,7 @@
= _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) }
- else
%span.token-never-expires-label= _('Never')
- %td= token.scopes.present? ? token.scopes.join(', ') : html_escape_once(_('&lt;no scopes selected&gt;')).html_safe
+ %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
%td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: '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 } }
- else
.settings-message.text-center
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index ce48691166b..e4222d8a4fe 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -13,27 +13,15 @@
- 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
- %script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board"
= render 'shared/issuable/search_bar', type: :boards, board: board
#board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
- - if Feature.enabled?(:boards_with_swimlanes, current_board_parent, default_enabled: true) || Feature.enabled?(:graphql_board_lists, current_board_parent)
- %board-content{ "v-cloak" => "true",
- "ref" => "board_content",
- ":lists" => "state.lists",
- ":can-admin-list" => can_admin_list,
- ":disabled" => "disabled" }
- - else
- .boards-list.w-100.py-3.px-2.text-nowrap{ data: { qa_selector: "boards_list" } }
- .boards-app-loading.w-100.text-center{ "v-if" => "loading" }
- = loading_icon(css_class: 'gl-mb-3')
- %board{ "v-cloak" => "true",
- "v-for" => "list in state.lists",
- "ref" => "board",
- ":can-admin-list" => can_admin_list,
- ":list" => "list",
- ":disabled" => "disabled",
- ":key" => "list.id" }
+ %board-content{ "v-cloak" => "true",
+ "ref" => "board_content",
+ ":lists" => "state.lists",
+ ":can-admin-list" => can_admin_list,
+ ":disabled" => "disabled",
+ data: { qa_selector: "boards_list" } }
= render "shared/boards/components/sidebar", group: group
%board-settings-sidebar{ ":can-admin-list" => can_admin_list }
- if @project
diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml
index ad73442807e..361471af0ad 100644
--- a/app/views/shared/deploy_tokens/_table.html.haml
+++ b/app/views/shared/deploy_tokens/_table.html.haml
@@ -23,7 +23,7 @@
In #{distance_of_time_in_words_to_now(token.expires_at)}
- else
%span.token-never-expires-label= _('Never')
- %td= token.scopes.present? ? token.scopes.join(", ") : html_escape_once(_('&lt;no scopes selected&gt;')).html_safe
+ %td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
%td= link_to s_('DeployTokens|Revoke'), "#", class: "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
- else
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 9d2d3ce20c7..75c34102935 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -1,24 +1,17 @@
- options_hash = local_assigns.fetch(:options_hash, groups_sort_options_hash)
- show_archive_options = local_assigns.fetch(:show_archive_options, false)
-- if @sort.present?
- - default_sort_by = @sort
-- else
- - if params[:sort]
- - default_sort_by = params[:sort]
- - else
- - default_sort_by = sort_value_recently_created
.dropdown.inline.js-group-filter-dropdown-wrap.gl-mr-3
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.dropdown-label
- = options_hash[default_sort_by]
- = icon('chevron-down')
+ = options_hash[project_list_sort_by]
+ = 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
= _("Sort by")
- options_hash.each do |value, title|
%li.js-filter-sort-order
- = link_to filter_groups_path(sort: value), class: ("is-active" if default_sort_by == value) do
+ = link_to filter_groups_path(sort: value), class: ("is-active" if project_list_sort_by == value) do
= title
- if show_archive_options
%li.divider
diff --git a/app/views/shared/groups/_visibility_level.html.haml b/app/views/shared/groups/_visibility_level.html.haml
new file mode 100644
index 00000000000..1a13de9b76a
--- /dev/null
+++ b/app/views/shared/groups/_visibility_level.html.haml
@@ -0,0 +1,3 @@
+= f.label :visibility_level, class: 'label-bold' do
+ = _('Visibility level')
+.js-visibility-level-dropdown{ data: { visibility_level_options: visibility_level_options(@group).to_json, default_level: f.object.visibility_level } }
diff --git a/app/views/shared/icons/_icon_mattermost.svg b/app/views/shared/icons/_icon_mattermost.svg
index d1c541523ab..3cf10851003 100644
--- a/app/views/shared/icons/_icon_mattermost.svg
+++ b/app/views/shared/icons/_icon_mattermost.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><path d="M250.05 34c1.9.04 3.8.11 5.6.2l-29.79 35.51c-.07.01-.15.03-.23.04C149.26 84.1 98.22 146.5 98.22 222.97c0 41.56 23.07 90.5 59.75 119.1 28.61 22.32 64.29 36.9 101.21 36.9 93.4 0 160.15-68.61 160.15-156 0-34.91-15.99-72.77-41.76-100.76l-1.63-47.39c54.45 39.15 89.95 103.02 90.06 175.17v.01c0 119.29-96.7 216-216 216-119.29 0-216-96.71-216-216S130.71 34 250 34h.05zm64.1 20.29c.66-.04 1.32.03 1.96.25 3.01 1 3.85 3.57 3.93 6.45l3.84 146.88c.76 28.66-17.16 68.44-60.39 68.56-30.97.08-63.68-20.83-63.68-60.13.01-14.73 5.61-31.26 19.25-48.11l90.03-111.18c1.15-1.42 3.08-2.58 5.06-2.72z"/></svg>
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 500 500"><path d="M250.05 34c1.9.04 3.8.11 5.6.2l-29.79 35.51c-.07.01-.15.03-.23.04C149.26 84.1 98.22 146.5 98.22 222.97c0 41.56 23.07 90.5 59.75 119.1 28.61 22.32 64.29 36.9 101.21 36.9 93.4 0 160.15-68.61 160.15-156 0-34.91-15.99-72.77-41.76-100.76l-1.63-47.39c54.45 39.15 89.95 103.02 90.06 175.17v.01c0 119.29-96.7 216-216 216-119.29 0-216-96.71-216-216S130.71 34 250 34h.05zm64.1 20.29c.66-.04 1.32.03 1.96.25 3.01 1 3.85 3.57 3.93 6.45l3.84 146.88c.76 28.66-17.16 68.44-60.39 68.56-30.97.08-63.68-20.83-63.68-60.13.01-14.73 5.61-31.26 19.25-48.11l90.03-111.18c1.15-1.42 3.08-2.58 5.06-2.72z"/></svg>
diff --git a/app/views/shared/integrations/_index.html.haml b/app/views/shared/integrations/_index.html.haml
index 2f299ad5c89..edc85f04d91 100644
--- a/app/views/shared/integrations/_index.html.haml
+++ b/app/views/shared/integrations/_index.html.haml
@@ -1,4 +1,4 @@
-%table.table.b-table.gl-table.mt-3{ role: 'table', 'aria-busy': false, 'aria-colcount': 4 }
+%table.table.b-table.gl-table{ role: 'table', 'aria-busy': false, 'aria-colcount': 4 }
%colgroup
%col
%col
@@ -15,11 +15,10 @@
- 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 }
+ %td{ role: 'cell', 'aria-colindex': 1, 'aria-label': activated_label, title: activated_label }
= boolean_to_icon integration.operating?
%td{ role: 'cell', 'aria-colindex': 2 }
- = link_to scoped_edit_integration_path(integration), { data: { qa_selector: "#{integration.to_param}_link" } } do
- %strong= integration.title
+ = 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 }
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index 09abe9e89c4..2f30958c877 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -1,5 +1,5 @@
- type = local_assigns.fetch(:type)
-- bulk_issue_health_status_flag = Feature.enabled?(:bulk_update_health_status, @project&.group, default_enabled: true) && type == :issues && @project&.group&.feature_available?(:issuable_health_status)
+- bulk_issue_health_status_flag = type == :issues && @project&.group&.feature_available?(:issuable_health_status)
- epic_bulk_edit_flag = @project&.group&.feature_available?(:epics) && type == :issues
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
deleted file mode 100644
index 3453db9f209..00000000000
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ /dev/null
@@ -1,26 +0,0 @@
-- is_current_user = issuable_author_is_current_user(issuable)
-- display_issuable_type = issuable_display_type(issuable)
-- are_close_and_open_buttons_hidden = issuable_button_hidden?(issuable, true) && issuable_button_hidden?(issuable, false)
-- add_blocked_class = false
-- if defined? warn_before_close
- - add_blocked_class = warn_before_close
-
-- if is_current_user && !issuable.is_a?(MergeRequest)
- - if can_update
- %button{ class: "d-none d-md-block btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)} #{(add_blocked_class ? 'btn-issue-blocked' : '')}",
- data: { remote: 'true', endpoint: close_issuable_path(issuable), qa_selector: 'close_issue_button' } }
- = _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }
- - if can_reopen
- %button{ class: "d-none d-md-block btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}",
- data: { remote: 'true', endpoint: reopen_issuable_path(issuable), qa_selector: 'reopen_issue_button' } }
- = _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }
-- else
- - if can_update && !are_close_and_open_buttons_hidden
- - if issuable.is_a?(MergeRequest)
- = render 'shared/issuable/close_reopen_draft_report_toggle', issuable: issuable
- - else
- = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable, warn_before_close: add_blocked_class
- - else
- - unless issuable.is_a?(MergeRequest) && issuable.merged?
- = link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
- class: 'd-none d-md-block btn btn-grouped btn-close-color', title: _('Report abuse')
diff --git a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml
deleted file mode 100644
index bdb53dfe323..00000000000
--- a/app/views/shared/issuable/_close_reopen_draft_report_toggle.html.haml
+++ /dev/null
@@ -1,37 +0,0 @@
-- display_issuable_type = issuable_display_type(issuable)
-- button_action_class = issuable.closed? ? 'btn-default' : 'btn-warning btn-warning-secondary'
-- button_class = "btn gl-button #{!issuable.closed? && 'js-draft-toggle-button'}"
-- toggle_class = "btn gl-button dropdown-toggle"
-
-.float-left.btn-group.gl-ml-3.issuable-close-dropdown.d-none.d-md-inline-flex.js-issuable-close-dropdown
- = link_to issuable.closed? ? reopen_issuable_path(issuable) : toggle_draft_issuable_path(issuable), method: :put, class: "#{button_class} #{button_action_class}" do
- - if issuable.closed?
- = _('Reopen')
- = display_issuable_type
- - else
- = issuable.work_in_progress? ? _('Mark as ready') : _('Mark as draft')
-
- - if !issuable.closed? || !issuable_author_is_current_user(issuable)
- = button_tag type: 'button', class: "#{toggle_class} #{button_action_class}", data: { 'toggle' => 'dropdown' } do
- %span.sr-only= _('Toggle dropdown')
- = sprite_icon "angle-down", size: 12
-
- %ul.js-issuable-close-menu.dropdown-menu.dropdown-menu-right
- - if issuable.open?
- %li
- = link_to close_issuable_path(issuable), method: :put do
- .description
- %strong.title
- = _('Close')
- = display_issuable_type
-
- - unless issuable_author_is_current_user(issuable)
- - unless issuable.closed?
- %li.divider.droplab-item-ignore
-
- %li.report-item
- %a.report-abuse-link{ href: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)) }
- .description
- %strong.title= _('Report abuse')
- %p.text
- = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize }
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
deleted file mode 100644
index 48d1e146629..00000000000
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ /dev/null
@@ -1,47 +0,0 @@
-- display_issuable_type = issuable_display_type(issuable)
-- button_action = issuable.closed? ? 'reopen' : 'close'
-- display_button_action = button_action.capitalize
-- button_responsive_class = 'd-none d-md-block'
-- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
-- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
-- add_blocked_class = false
-- if defined? warn_before_close
- - add_blocked_class = !issuable.closed? && warn_before_close
-
-.float-left.btn-group.gl-ml-3.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
- %button{ class: "#{button_class} btn-#{button_action} #{(add_blocked_class ? 'btn-issue-blocked' : '')}", data: { testid: 'close-issue-button', qa_selector: 'close_issue_button', endpoint: close_reopen_issuable_path(issuable) } }
- #{display_button_action} #{display_issuable_type}
-
- = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color",
- data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => _('Toggle dropdown') do
- = icon('caret-down', class: 'toggle-icon icon')
-
- %ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ data: { dropdown: true } }
- %li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}",
- data: { text: _("Close %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: close_issuable_path(issuable),
- button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color" } }
- %button.btn.btn-transparent
- = sprite_icon('check', css_class: 'icon')
- .description
- %strong.title
- = _('Close')
- = display_issuable_type
-
- %li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}",
- data: { text: _("Reopen %{display_issuable_type}") % { display_issuable_type: display_issuable_type }, url: reopen_issuable_path(issuable),
- button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color" } }
- %button.btn.btn-transparent
- = sprite_icon('check', css_class: 'icon')
- .description
- %strong.title
- = _('Reopen')
- = display_issuable_type
-
- %li.divider.droplab-item-ignore
-
- %li.report-item{ data: { text: _('Report abuse'), button_class: "#{button_class} btn-close-color", toggle_class: "#{toggle_class} btn-close-color", method: '' } }
- %a.report-abuse-link{ :href => new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)) }
- .description
- %strong.title= _('Report abuse')
- %p.text
- = _('Report %{display_issuable_type} that are abusive, inappropriate or spam.') % { display_issuable_type: display_issuable_type.pluralize }
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index c0aba0eef7f..552f83906e1 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -32,7 +32,7 @@
= form.label :confidential, class: 'form-check-label' do
This issue is confidential and should only be visible to team members with at least Reporter access.
-= render 'shared/issuable/form/metadata', issuable: issuable, form: form, project: project
+= render 'shared/issuable/form/metadata', issuable: issuable, form: form, project: project, presenter: presenter
= render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form
@@ -88,3 +88,6 @@
= form.hidden_field :issue_type
= form.hidden_field :lock_version
+
+- if @vulnerability_id
+ = hidden_field_tag 'vulnerability_id', @vulnerability_id
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 00b235809ed..79d86500bd9 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -75,6 +75,22 @@
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
+ #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' }
+ = _('None')
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Any')
+ %li.divider.droplab-item-ignore
+ - if current_user
+ = render 'shared/issuable/user_dropdown_item',
+ user: current_user
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ = render 'shared/issuable/user_dropdown_item',
+ user: User.new(username: '{{username}}', name: '{{name}}'),
+ avatar: { lazy: true, url: '{{avatar_url}}' }
= render_if_exists 'shared/issuable/approver_dropdown'
= render_if_exists 'shared/issuable/approved_by_dropdown'
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
@@ -182,7 +198,7 @@
= render 'shared/issuable/board_create_list_dropdown', board: board
- if @project
#js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
- - if current_user && Feature.enabled?(:boards_with_swimlanes, @group, default_enabled: true)
+ - if current_user
#js-board-epics-swimlanes-toggle
#js-toggle-focus-btn
- elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 1f20c1a30aa..cd265c10451 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -25,7 +25,7 @@
.block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees, signed_in: signed_in
- - if Feature.enabled?(:merge_request_reviewers, @project) && reviewers
+ - if Feature.enabled?(:merge_request_reviewers, @project, default_enabled: true) && reviewers
.block.reviewer.qa-reviewer-block
= render "shared/issuable/sidebar_reviewers", issuable_sidebar: issuable_sidebar, reviewers: reviewers, signed_in: signed_in
@@ -58,7 +58,7 @@
= f.hidden_field 'milestone_id', value: milestone[:id], id: nil
= dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
- if @project.group.present?
- = render_if_exists 'shared/issuable/iteration_select', { can_edit: can_edit_issuable, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type }
+ = render_if_exists 'shared/issuable/iteration_select', can_edit: can_edit_issuable, group_path: @project.group.full_path, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid], issuable_type: issuable_type
- if issuable_sidebar[:supports_time_tracking]
#issuable-time-tracker.block
diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml
index 94fa43746e2..a425f5f810e 100644
--- a/app/views/shared/issuable/form/_branch_chooser.html.haml
+++ b/app/views/shared/issuable/form/_branch_chooser.html.haml
@@ -2,7 +2,7 @@
- form = local_assigns.fetch(:form)
- return unless issuable.is_a?(MergeRequest)
-- return if issuable.closed_without_fork?
+- return if issuable.closed_or_merged_without_fork?
- source_title, target_title = format_mr_branch_names(@merge_request)
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index e29627304b4..7233e671caa 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -2,7 +2,7 @@
- project = local_assigns.fetch(:project)
- return unless issuable.is_a?(MergeRequest)
-- return if issuable.closed_without_fork?
+- return if issuable.closed_or_merged_without_fork?
.form-group.row
.col-sm-2.col-form-label.pt-sm-0
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 459eb112e4f..366e819d252 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -1,5 +1,6 @@
- project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable)
+- presenter = local_assigns.fetch(:presenter)
- return unless can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project)
@@ -14,7 +15,7 @@
- if issuable.allows_reviewers?
.form-group.row.merge-request-reviewer
- = render "shared/issuable/form/metadata_issuable_reviewer", issuable: issuable, form: form, has_due_date: has_due_date
+ = render "shared/issuable/form/metadata_issuable_reviewer", issuable: issuable, form: form, has_due_date: has_due_date, presenter: presenter
= render_if_exists "shared/issuable/form/epic", issuable: issuable, form: form, project: project
diff --git a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
index 60dc893d9f9..b437ee1ec5f 100644
--- a/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
@@ -1,4 +1,4 @@
-= form.label :assignee_id, "Assignee", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
+= form.label :assignee_id, issuable.allows_multiple_assignees? ? _('Assignees') : _('Assignee'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
.col-sm-10{ class: ("col-md-8" if has_due_date) }
.issuable-form-select-holder.selectbox
- issuable.assignees.each do |assignee|
diff --git a/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml b/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml
index a8b033bba36..a0df007f8ca 100644
--- a/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issuable_reviewer.html.haml
@@ -1,5 +1,5 @@
-= form.label :reviewer_id, "Reviewer", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
-.col-sm-10{ class: ("col-md-8" if has_due_date) }
+= form.label :reviewer_id, issuable.allows_multiple_reviewers? ? _('Reviewers') : _('Reviewer'), class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
+.col-sm-10.gl-mb-2{ class: ("col-md-8" if has_due_date) }
.issuable-form-select-holder.selectbox
- issuable.reviewers.each do |reviewer|
= hidden_field_tag "#{issuable.to_ability_name}[reviewer_ids][]", reviewer.id, id: nil, data: { meta: reviewer.name, avatar_url: reviewer.avatar_url, name: reviewer.name, username: reviewer.username }
@@ -7,4 +7,6 @@
- if issuable.reviewers.empty?
= hidden_field_tag "#{issuable.to_ability_name}[reviewer_ids][]", 0, id: nil, data: { meta: '' }
- = dropdown_tag(users_dropdown_label(issuable.reviewers), options: reviewers_dropdown_options(issuable.to_ability_name))
+ = dropdown_tag(users_dropdown_label(issuable.reviewers), options: reviewers_dropdown_options(issuable.to_ability_name, issuable.iid, issuable.target_branch))
+ - if Feature.enabled?(:mr_collapsed_approval_rules, @project)
+ = render_if_exists 'shared/issuable/approver_suggestion', issuable: issuable, presenter: presenter
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index 5d64c15d9f9..67bc4019a82 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -13,7 +13,7 @@
.dropdown-title.gl-display-flex
%span.gl-ml-auto
= _("Select type")
- %button.dropdown-title-button.dropdown-menu-close.gl-ml-auto{ "aria-label" => _('Close') }
+ %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
diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml
index ea4df288839..d6226760ba5 100644
--- a/app/views/shared/issue_type/_details_header.html.haml
+++ b/app/views/shared/issue_type/_details_header.html.haml
@@ -1,10 +1,3 @@
-- can_update_issue = can?(current_user, :update_issue, issuable)
-- can_reopen_issue = can?(current_user, :reopen_issue, issuable)
-- can_report_spam = issuable.submittable_as_spam_by?(current_user)
-- can_create_issue = show_new_issue_link?(@project)
-- display_issuable_type = issuable_display_type(issuable)
-- new_issuable_params = ({ issuable_template: 'incident', issue: { issue_type: 'incident' } } if issuable.incident?)
-
.detail-page-header
.detail-page-header-body
.issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(issuable, status_box: :closed) }
@@ -18,38 +11,9 @@
.issuable-meta
#js-issuable-header-warnings
- = issuable_meta(issuable, @project, display_issuable_type)
+ = 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: "#" }
= sprite_icon('chevron-double-lg-left')
- - if Feature.enabled?(:vue_issue_header, @project, default_enabled: true)
- .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user) }
- - else
- .detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } }
- .clearfix.issue-btn-group.dropdown
- %button.btn.gl-button.btn-default.float-left.gl-display-md-none{ type: "button", data: { toggle: "dropdown" } }
- = _('Options')
- = icon('caret-down')
- .dropdown-menu.dropdown-menu-right
- %ul
- - unless current_user == issuable.author
- %li= link_to _('Report abuse'), new_abuse_report_path(user_id: issuable.author.id, ref_url: issue_url(issuable))
- - if can_update_issue
- %li= link_to _('Close %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, issue_path(issuable, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(issuable, true)}", title: _('Close %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, data: { endpoint: close_reopen_issuable_path(issuable) }
- - if can_reopen_issue
- %li= link_to _('Reopen %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, issue_path(issuable, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(issuable, false)}", title: _('Reopen %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, data: { endpoint: close_reopen_issuable_path(issuable) }
- - if can_report_spam
- %li= link_to _('Submit as spam'), mark_as_spam_project_issue_path(@project, issuable), method: :post, class: 'btn-spam', title: 'Submit as spam'
- - if can_create_issue
- - if can_update_issue || can_report_spam
- %li.divider
- %li= link_to _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, new_project_issue_path(@project, new_issuable_params), id: 'new_%{display_issuable_type}_link' % { display_issuable_type: display_issuable_type }
-
- = render 'shared/issuable/close_reopen_button', issuable: issuable, can_update: can_update_issue, can_reopen: can_reopen_issue, warn_before_close: defined?(issuable.blocked?) && issuable.blocked?
-
- - if can_report_spam
- = link_to _('Submit as spam'), mark_as_spam_project_issue_path(@project, issuable), method: :post, class: 'gl-display-none gl-display-md-block gl-button btn btn-grouped btn-spam', title: 'Submit as spam'
- - if can_create_issue
- = link_to new_project_issue_path(@project, new_issuable_params), class: 'gl-display-none gl-display-md-block gl-button btn btn-grouped btn-success btn-inverted', title: _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type }, id: 'new_%{display_issuable_type}_link' % { display_issuable_type: display_issuable_type } do
- = _('New %{display_issuable_type}') % { display_issuable_type: display_issuable_type }
+ .js-issue-header-actions{ data: issue_header_actions_data(@project, issuable, current_user) }
diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml
index 07e96eea062..cfc00bd41ca 100644
--- a/app/views/shared/labels/_sort_dropdown.html.haml
+++ b/app/views/shared/labels/_sort_dropdown.html.haml
@@ -2,7 +2,7 @@
.dropdown.inline
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
= sort_title
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort
%li
- label_sort_options_hash.each do |value, title|
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 42e12d92a7d..d98ba074687 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -27,7 +27,7 @@
data: { toggle: "dropdown", field_name: "group_link[group_access]" } }
%span.dropdown-toggle-text
= group_link.human_access
- = icon("chevron-down")
+ = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable
= dropdown_title(_("Change permissions"))
.dropdown-content
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index e294936f82c..79bbb74d601 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -79,7 +79,7 @@
data: { toggle: "dropdown", field_name: "#{f.object_name}[access_level]", qa_selector: "access_level_dropdown" } }
%span.dropdown-toggle-text
= member.human_access
- = icon("chevron-down")
+ = sprite_icon("chevron-down", css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-right.dropdown-menu-selectable
= dropdown_title(_("Change permissions"))
.dropdown-content
diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml
index 93da319fce7..19ca00ce482 100644
--- a/app/views/shared/milestones/_header.html.haml
+++ b/app/views/shared/milestones/_header.html.haml
@@ -28,7 +28,7 @@
- if milestone.active?
= link_to _('Close milestone'), update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'btn gl-button btn-grouped btn-close'
- else
- = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn gl-button btn-grouped btn-reopen'
+ = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn gl-button btn-grouped'
= render 'shared/milestones/delete_button'
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index 1597a011a45..92ac6929e6a 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -59,6 +59,6 @@
- if can?(current_user, :admin_milestone, milestone)
- if milestone.closed?
- = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn gl-button btn-sm btn-grouped btn-reopen"
+ = link_to s_('Milestones|Reopen Milestone'), milestone_path(milestone, milestone: { state_event: :activate }), method: :put, class: "btn gl-button btn-sm btn-grouped"
- 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"
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index 45af4b51b27..eb03608e18a 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -1,11 +1,11 @@
- 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.btn-nr.btn-success.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } }
+ %input.btn.btn-success.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: 'btn btn-nr 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
- = icon('caret-down', class: 'toggle-icon')
+ = button_tag type: 'button', class: '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
+ = 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 } } }
diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml
index 79feb12bed5..d783fa0d777 100644
--- a/app/views/shared/notes/_edit_form.html.haml
+++ b/app/views/shared/notes/_edit_form.html.haml
@@ -9,6 +9,6 @@
.note-form-actions.clearfix
.settings-message.note-edit-warning.js-finish-edit-warning
= _("Finish editing this message first!")
- = submit_tag _('Save comment'), class: 'btn btn-nr btn-success js-comment-save-button', data: { qa_selector: 'save_comment_button' }
- %button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
+ = submit_tag _('Save comment'), class: 'btn btn-success js-comment-save-button', data: { qa_selector: 'save_comment_button' }
+ %button.btn.btn-cancel.note-edit-cancel{ type: 'button' }
= _("Cancel")
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index f1686417f8d..2cf074b9d3f 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -38,7 +38,5 @@
.note-form-actions.clearfix
= render partial: 'shared/notes/comment_button'
- = yield(:note_actions)
-
%a.btn.btn-cancel.js-close-discussion-note-form.hide{ role: "button", data: { cancel_text: _("Cancel") } }
= _('Cancel')
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index d7b53810f76..e12531b8a8d 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -20,8 +20,8 @@
%button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
= sprite_icon("notifications", css_class: "js-notification-loading")
= notification_title(notification_setting.level)
- %button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
- = icon('caret-down')
+ %button.btn.dropdown-toggle.gl-display-flex.gl-align-items-center{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ = sprite_icon('chevron-down')
.sr-only Toggle dropdown
- else
%button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
@@ -29,7 +29,7 @@
= sprite_icon("notifications", css_class: "js-notification-loading")
= notification_title(notification_setting.level)
.float-right
- = icon("caret-down")
+ = sprite_icon("chevron-down")
= render "shared/notifications/notification_dropdown", notification_setting: notification_setting
diff --git a/app/views/shared/projects/_sort_dropdown.html.haml b/app/views/shared/projects/_sort_dropdown.html.haml
index f5f940db189..3e810dc6f08 100644
--- a/app/views/shared/projects/_sort_dropdown.html.haml
+++ b/app/views/shared/projects/_sort_dropdown.html.haml
@@ -5,7 +5,7 @@
.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' } }
= toggle_text
- = icon('chevron-down')
+ = sprite_icon('chevron-down', css_class: 'dropdown-menu-toggle-icon gl-top-3')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
= _("Sort by")
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 eafc402f210..cb954c20b48 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
@@ -1,3 +1,5 @@
+- select_mode_for_dropdown = Feature.enabled?(:deploy_keys_on_protected_branches, protected_branch.project) ? 'js-multiselect' : ''
+
- merge_access_levels = protected_branch.merge_access_levels.for_role
- push_access_levels = protected_branch.push_access_levels.for_role
@@ -23,7 +25,7 @@
%td.push_access_levels-container
= hidden_field_tag "allowed_to_push_#{protected_branch.id}", push_access_levels.first&.access_level
= dropdown_tag( (push_access_levels.first&.humanize || 'Select') ,
- options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header',
+ options: { toggle_class: "js-allowed-to-push #{select_mode_for_dropdown}", dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header',
data: { field_name: "allowed_to_push_#{protected_branch.id}", preselected_items: access_levels_data(push_access_levels) }})
- if user_push_access_levels.any?
%p.small
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index c5234f14090..c37a34f9be8 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -10,89 +10,91 @@
= s_('Webhooks|Use this token to validate received payloads. It will be sent with the request in the X-Gitlab-Token HTTP header.')
.form-group
= form.label :url, s_('Webhooks|Trigger'), class: 'label-bold'
- %ul.list-unstyled.prepend-left-20
+ %ul.list-unstyled.gl-ml-6
%li
= form.check_box :push_events, class: 'form-check-input'
- = form.label :push_events, class: 'list-label form-check-label ml-1' do
+ = form.label :push_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Push events')
= form.text_field :push_events_branch_filter, class: 'form-control', placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)'
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered by a push to the repository')
%li
= form.check_box :tag_push_events, class: 'form-check-input'
- = form.label :tag_push_events, class: 'list-label form-check-label ml-1' do
+ = form.label :tag_push_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Tag push events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when a new tag is pushed to the repository')
%li
= form.check_box :note_events, class: 'form-check-input'
- = form.label :note_events, class: 'list-label form-check-label ml-1' do
+ = form.label :note_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Comments')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when someone adds a comment')
%li
= form.check_box :confidential_note_events, class: 'form-check-input'
- = form.label :confidential_note_events, class: 'list-label form-check-label ml-1' do
+ = form.label :confidential_note_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Confidential Comments')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when someone adds a comment on a confidential issue')
%li
= form.check_box :issues_events, class: 'form-check-input'
- = form.label :issues_events, class: 'list-label form-check-label ml-1' do
+ = form.label :issues_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Issues events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when an issue is created/updated/merged')
%li
= form.check_box :confidential_issues_events, class: 'form-check-input'
- = form.label :confidential_issues_events, class: 'list-label form-check-label ml-1' do
+ = form.label :confidential_issues_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Confidential Issues events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when a confidential issue is created/updated/merged')
+ - if @group
+ = render_if_exists 'groups/hooks/member_events', form: form
%li
= form.check_box :merge_requests_events, class: 'form-check-input'
- = form.label :merge_requests_events, class: 'list-label form-check-label ml-1' do
+ = form.label :merge_requests_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Merge request events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when a merge request is created/updated/merged')
%li
= form.check_box :job_events, class: 'form-check-input'
- = form.label :job_events, class: 'list-label form-check-label ml-1' do
+ = form.label :job_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Job events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when the job status changes')
%li
= form.check_box :pipeline_events, class: 'form-check-input'
- = form.label :pipeline_events, class: 'list-label form-check-label ml-1' do
+ = form.label :pipeline_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Pipeline events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when the pipeline status changes')
%li
= form.check_box :wiki_page_events, class: 'form-check-input'
- = form.label :wiki_page_events, class: 'list-label form-check-label ml-1' do
+ = form.label :wiki_page_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Wiki Page events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL will be triggered when a wiki page is created/updated')
%li
= form.check_box :deployment_events, class: 'form-check-input'
- = form.label :deployment_events, class: 'list-label form-check-label ml-1' do
+ = form.label :deployment_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Deployment events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL is triggered when a deployment starts, finishes, fails, or is canceled')
%li
= form.check_box :feature_flag_events, class: 'form-check-input'
- = form.label :feature_flag_events, class: 'list-label form-check-label ml-1' do
+ = form.label :feature_flag_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Feature Flag events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL is triggered when a feature flag is turned on or off')
%li
= form.check_box :releases_events, class: 'form-check-input'
- = form.label :releases_events, class: 'list-label form-check-label ml-1' do
+ = form.label :releases_events, class: 'list-label form-check-label gl-ml-1' do
%strong= s_('Webhooks|Releases events')
- %p.text-muted.ml-1
+ %p.text-muted.gl-ml-1
= s_('Webhooks|This URL is triggered when a release is created/updated')
.form-group
= form.label :enable_ssl_verification, s_('Webhooks|SSL verification'), class: 'label-bold checkbox'
.form-check
= form.check_box :enable_ssl_verification, class: 'form-check-input'
- = form.label :enable_ssl_verification, class: 'form-check-label ml-1' do
+ = form.label :enable_ssl_verification, class: 'form-check-label gl-ml-1' do
%strong= s_('Webhooks|Enable SSL verification')
diff --git a/app/views/shared/web_hooks/_test_button.html.haml b/app/views/shared/web_hooks/_test_button.html.haml
index fc24e425ab6..c46b8a99886 100644
--- a/app/views/shared/web_hooks/_test_button.html.haml
+++ b/app/views/shared/web_hooks/_test_button.html.haml
@@ -5,7 +5,7 @@
.hook-test-button.dropdown.inline>
%button.btn{ 'data-toggle' => 'dropdown', class: button_class }
= _('Test')
- = icon('caret-down')
+ = sprite_icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right{ role: 'menu' }
- triggers.each_value do |event|
%li
diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml
index dde1b3afa2d..b6504c7a17e 100644
--- a/app/views/shared/wikis/_form.html.haml
+++ b/app/views/shared/wikis/_form.html.haml
@@ -36,7 +36,7 @@
.col-sm-10
.select-wrapper
= f.select :format, options_for_select(Wiki::MARKUPS, {selected: @page.format}), {}, class: 'form-control select-control'
- = icon('chevron-down')
+ = 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'
diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml
index c0ed7b4c6f2..a906bf7aa63 100644
--- a/app/views/shared/wikis/_sidebar.html.haml
+++ b/app/views/shared/wikis/_sidebar.html.haml
@@ -4,17 +4,19 @@
%a.gutter-toggle.float-right.d-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" }
= sprite_icon('chevron-double-lg-right', css_class: 'gl-icon')
- - if @wiki.container.is_a?(Project)
- - git_access_url = wiki_path(@wiki, action: :git_access)
- = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do
- = sprite_icon('download', css_class: 'gl-mr-2')
- %span= _("Clone repository")
+ - git_access_url = wiki_path(@wiki, action: :git_access)
+ = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do
+ = sprite_icon('download', css_class: 'gl-mr-2')
+ %span= _("Clone repository")
+
+ - if @sidebar_error.present?
+ = render 'shared/alert_info', body: s_('Wiki|The sidebar failed to load. You can reload the page to try again.')
.blocks-container
.block.block-first.w-100
- if @sidebar_page
= render_wiki_content(@sidebar_page)
- - else
+ - elsif @sidebar_wiki_entries
%ul.wiki-pages
= render @sidebar_wiki_entries, context: 'sidebar'
.block.w-100
diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/shared/wikis/git_access.html.haml
index c166642bae2..2542860c742 100644
--- a/app/views/projects/wikis/git_access.html.haml
+++ b/app/views/shared/wikis/git_access.html.haml
@@ -11,7 +11,7 @@
%strong= @wiki.full_path
.pt-3.pt-lg-0.w-100
- = render "shared/clone_panel", project: @wiki
+ = render "shared/clone_panel", container: @wiki
.wiki-git-access
%h3= s_("WikiClone|Install Gollum")
diff --git a/app/views/shared/wikis/git_error.html.haml b/app/views/shared/wikis/git_error.html.haml
new file mode 100644
index 00000000000..dab3b940b9a
--- /dev/null
+++ b/app/views/shared/wikis/git_error.html.haml
@@ -0,0 +1,14 @@
+- if @page
+ - wiki_page_title @page
+
+- add_page_specific_style 'page_bundles/wiki'
+
+- git_access_url = wiki_path(@wiki, action: :git_access)
+
+.wiki-page-header.top-area.gl-flex-direction-column.gl-lg-flex-direction-row
+ .gl-mt-5.gl-mb-3
+ .gl-display-flex.gl-justify-content-space-between
+ %h2.gl-mt-0.gl-mb-5{ data: { qa_selector: 'wiki_page_title', testid: 'wiki_page_title' } }= @page ? @page.human_title : _('Failed to retrieve page')
+ .js-wiki-page-content.md.gl-pt-2{ data: { qa_selector: 'wiki_page_content', testid: 'wiki_page_content' } }
+ = _('The page could not be displayed because it timed out.')
+ = html_escape(_('You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}')) % { linkStart: "<a href=\"#{git_access_url}\">".html_safe, linkEnd: '</a>'.html_safe, cloneIcon: sprite_icon('download', css_class: 'gl-mr-2').html_safe }
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index 1367d80cf54..a78971967ff 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -18,7 +18,7 @@
%h4.gl-flex-grow-1
= Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity')
= link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
- .overview-content-list{ data: { href: user_path } }
+ .overview-content-list{ data: { href: user_activity_path } }
.center.light.loading
.spinner.spinner-md
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index ee037a7d66a..9f6b0bc2373 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -1,7 +1,7 @@
- @hide_top_links = true
- @hide_breadcrumbs = true
- @no_container = true
-- page_title @user.blocked? ? s_('UserProfile|Blocked user') : @user.name
+- page_title user_display_name(@user)
- page_description @user.bio_html
- header_title @user.name, user_path(@user)
- page_itemtype 'http://schema.org/Person'
@@ -38,10 +38,10 @@
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: '', itemprop: 'image'
- - if @user.blocked?
+ - if @user.blocked? || !@user.confirmed?
.user-info
.cover-title
- = s_('UserProfile|Blocked user')
+ = user_display_name(@user)
= render "users/profile_basic_info"
- else
.user-info
@@ -139,7 +139,7 @@
- if can?(current_user, :read_cross_project)
%h4.prepend-top-20
= s_('UserProfile|Most Recent Activity')
- .content_list{ data: { href: user_path } }
+ .content_list{ data: { href: user_activity_path } }
.loading
.spinner.spinner-md
- unless @user.bot?
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 6f080a97f7a..1f2e8213b64 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -124,7 +124,7 @@
:idempotent:
:tags: []
- :name: cronjob:analytics_instance_statistics_count_job_trigger
- :feature_category: :instance_statistics
+ :feature_category: :devops_reports
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -323,6 +323,22 @@
:weight: 1
:idempotent:
:tags: []
+- :name: cronjob:releases_create_evidence
+ :feature_category: :release_evidence
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
+- :name: cronjob:releases_manage_evidence
+ :feature_category: :release_evidence
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: cronjob:remove_expired_group_links
:feature_category: :authentication_and_authorization
:has_external_dependencies:
@@ -380,7 +396,7 @@
:idempotent:
:tags: []
- :name: cronjob:schedule_merge_request_cleanup_refs
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -388,7 +404,7 @@
:idempotent: true
:tags: []
- :name: cronjob:schedule_migrate_external_diffs
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -412,7 +428,7 @@
:idempotent:
:tags: []
- :name: cronjob:stuck_merge_jobs
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -691,6 +707,22 @@
:weight: 1
:idempotent:
:tags: []
+- :name: github_importer:github_import_import_pull_request_merged_by
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
+- :name: github_importer:github_import_import_pull_request_review
+ :feature_category: :importers
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: github_importer:github_import_refresh_import_jid
:feature_category: :importers
:has_external_dependencies:
@@ -747,6 +779,22 @@
:weight: 1
:idempotent:
:tags: []
+- :name: github_importer:github_import_stage_import_pull_requests_merged_by
+ :feature_category: :importers
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
+- :name: github_importer:github_import_stage_import_pull_requests_reviews
+ :feature_category: :importers
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent:
+ :tags: []
- :name: github_importer:github_import_stage_import_repository
:feature_category: :importers
:has_external_dependencies:
@@ -829,15 +877,23 @@
:tags: []
- :name: jira_connect:jira_connect_sync_branch
:feature_category: :integrations
- :has_external_dependencies:
+ :has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent:
:tags: []
+- :name: jira_connect:jira_connect_sync_builds
+ :feature_category: :integrations
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: jira_connect:jira_connect_sync_merge_request
:feature_category: :integrations
- :has_external_dependencies:
+ :has_external_dependencies: true
:urgency: :low
:resource_boundary: :unknown
:weight: 1
@@ -1045,6 +1101,14 @@
:weight: 1
:idempotent: true
:tags: []
+- :name: pipeline_background:ci_test_failure_history
+ :feature_category: :continuous_integration
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: pipeline_cache:expire_job_cache
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1313,7 +1377,15 @@
:idempotent: true
:tags: []
- :name: analytics_instance_statistics_counter_job
- :feature_category: :instance_statistics
+ :feature_category: :devops_reports
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
+- :name: approve_blocked_pending_approval_users
+ :feature_category: :users
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -1377,16 +1449,8 @@
:weight: 2
:idempotent: true
:tags: []
-- :name: create_evidence
- :feature_category: :release_evidence
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 2
- :idempotent:
- :tags: []
- :name: create_note_diff_file
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -1402,7 +1466,7 @@
:idempotent:
:tags: []
- :name: delete_diff_files
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -1497,6 +1561,14 @@
:weight: 2
:idempotent:
:tags: []
+- :name: environments_canary_ingress_update
+ :feature_category: :continuous_delivery
+ :has_external_dependencies: true
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: error_tracking_issue_link
:feature_category: :error_tracking
:has_external_dependencies: true
@@ -1562,6 +1634,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: gitlab_performance_bar_stats
+ :feature_category: :metrics
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: gitlab_shell
:feature_category: :source_code_management
:has_external_dependencies:
@@ -1601,7 +1681,7 @@
:urgency: :low
:resource_boundary: :cpu
:weight: 2
- :idempotent:
+ :idempotent: true
:tags: []
- :name: invalid_gpg_signature_update
:feature_category: :source_code_management
@@ -1660,7 +1740,7 @@
:idempotent:
:tags: []
- :name: merge_request_cleanup_refs
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -1668,7 +1748,7 @@
:idempotent: true
:tags: []
- :name: merge_request_mergeability_check
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -1692,7 +1772,7 @@
:idempotent: true
:tags: []
- :name: migrate_external_diffs
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
@@ -1707,6 +1787,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: namespaces_onboarding_user_added
+ :feature_category: :users
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: new_issue
:feature_category: :issue_tracking
:has_external_dependencies:
@@ -1716,7 +1804,7 @@
:idempotent:
:tags: []
- :name: new_merge_request
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
@@ -1812,7 +1900,7 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent: true
:tags: []
- :name: project_daily_statistics
:feature_category: :source_code_management
@@ -1839,6 +1927,14 @@
:weight: 1
:idempotent:
:tags: []
+- :name: project_schedule_bulk_repository_shard_moves
+ :feature_category: :gitaly
+ :has_external_dependencies:
+ :urgency: :throttled
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: project_service
:feature_category: :integrations
:has_external_dependencies: true
@@ -1973,7 +2069,7 @@
:urgency: :low
:resource_boundary: :unknown
:weight: 1
- :idempotent:
+ :idempotent: true
:tags: []
- :name: self_monitoring_project_create
:feature_category: :metrics
@@ -2024,7 +2120,7 @@
:idempotent: true
:tags: []
- :name: update_merge_requests
- :feature_category: :source_code_management
+ :feature_category: :code_review
:has_external_dependencies:
:urgency: :high
:resource_boundary: :cpu
diff --git a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
index bf57619fc6e..81a765d5d08 100644
--- a/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
+++ b/app/workers/analytics/instance_statistics/count_job_trigger_worker.rb
@@ -8,7 +8,7 @@ module Analytics
DEFAULT_DELAY = 3.minutes.freeze
- feature_category :instance_statistics
+ feature_category :devops_reports
urgency :low
idempotent!
diff --git a/app/workers/analytics/instance_statistics/counter_job_worker.rb b/app/workers/analytics/instance_statistics/counter_job_worker.rb
index 7fc715419b8..c07b2569453 100644
--- a/app/workers/analytics/instance_statistics/counter_job_worker.rb
+++ b/app/workers/analytics/instance_statistics/counter_job_worker.rb
@@ -5,7 +5,7 @@ module Analytics
class CounterJobWorker
include ApplicationWorker
- feature_category :instance_statistics
+ feature_category :devops_reports
urgency :low
idempotent!
diff --git a/app/workers/approve_blocked_pending_approval_users_worker.rb b/app/workers/approve_blocked_pending_approval_users_worker.rb
new file mode 100644
index 00000000000..8ca61d68bfd
--- /dev/null
+++ b/app/workers/approve_blocked_pending_approval_users_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ApproveBlockedPendingApprovalUsersWorker
+ include ApplicationWorker
+
+ idempotent!
+
+ feature_category :users
+
+ def perform(current_user_id)
+ current_user = User.find(current_user_id)
+
+ User.blocked_pending_approval.find_each do |user|
+ Users::ApproveService.new(current_user).execute(user)
+ end
+ end
+end
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index af2305528ce..d7a5fcf4f18 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -33,11 +33,6 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
BuildCoverageWorker.new.perform(build.id)
Ci::BuildReportResultWorker.new.perform(build.id)
- # TODO: As per https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/194, it may be
- # best to avoid creating more workers that we have no intention of calling async.
- # Change the previous worker calls on top to also just call the service directly.
- Ci::TestCasesService.new.execute(build)
-
# We execute these async as these are independent operations.
BuildHooksWorker.perform_async(build.id)
ExpirePipelineCacheWorker.perform_async(build.pipeline_id) if build.pipeline.cacheable?
diff --git a/app/workers/ci/test_failure_history_worker.rb b/app/workers/ci/test_failure_history_worker.rb
new file mode 100644
index 00000000000..e1562cb3836
--- /dev/null
+++ b/app/workers/ci/test_failure_history_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Ci
+ class TestFailureHistoryWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ idempotent!
+
+ def perform(pipeline_id)
+ Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline|
+ Ci::TestFailureHistoryService.new(pipeline).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/clusters/applications/check_prometheus_health_worker.rb b/app/workers/clusters/applications/check_prometheus_health_worker.rb
index 2e8ee739946..cf9534c9a78 100644
--- a/app/workers/clusters/applications/check_prometheus_health_worker.rb
+++ b/app/workers/clusters/applications/check_prometheus_health_worker.rb
@@ -20,7 +20,7 @@ module Clusters
demo_project_ids = Gitlab::Monitor::DemoProjects.primary_keys
clusters = Clusters::Cluster.with_application_prometheus
- .with_project_alert_service_data(demo_project_ids)
+ .with_project_http_integrations(demo_project_ids)
# Move to a seperate worker with scoped context if expanded to do work on customer projects
clusters.each { |cluster| Clusters::Applications::PrometheusHealthCheckService.new(cluster).execute }
diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb
index 63c1ba8e699..575cd4862b0 100644
--- a/app/workers/concerns/gitlab/github_import/object_importer.rb
+++ b/app/workers/concerns/gitlab/github_import/object_importer.rb
@@ -15,17 +15,25 @@ module Gitlab
feature_category :importers
worker_has_external_dependencies!
+
+ def logger
+ @logger ||= Gitlab::Import::Logger.build
+ end
end
# project - An instance of `Project` to import the data into.
# client - An instance of `Gitlab::GithubImport::Client`
# hash - A Hash containing the details of the object to import.
def import(project, client, hash)
- object = representation_class.from_json_hash(hash)
+ info(project.id, message: 'starting importer')
+ object = representation_class.from_json_hash(hash)
importer_class.new(object, project, client).execute
counter.increment
+ info(project.id, message: 'importer finished')
+ rescue => e
+ error(project.id, e)
end
def counter
@@ -52,6 +60,35 @@ module Gitlab
def counter_description
raise NotImplementedError
end
+
+ private
+
+ def info(project_id, extra = {})
+ logger.info(log_attributes(project_id, extra))
+ end
+
+ def error(project_id, exception)
+ logger.error(
+ log_attributes(
+ project_id,
+ message: 'importer failed',
+ 'error.message': exception.message
+ )
+ )
+
+ Gitlab::ErrorTracking.track_and_raise_exception(
+ exception,
+ log_attributes(project_id)
+ )
+ end
+
+ def log_attributes(project_id, extra = {})
+ extra.merge(
+ import_source: :github,
+ project_id: project_id,
+ importer: importer_class.name
+ )
+ end
end
end
end
diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
index 1c6413674a0..eb1af0869bd 100644
--- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb
@@ -6,7 +6,7 @@ module Gitlab
# importing GitHub projects.
module ReschedulingMethods
# project_id - The ID of the GitLab project to import the note into.
- # hash - A Hash containing the details of the GitHub object to imoprt.
+ # hash - A Hash containing the details of the GitHub object to import.
# notify_key - The Redis key to notify upon completion, if any.
# rubocop: disable CodeReuse/ActiveRecord
def perform(project_id, hash, notify_key = nil)
diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb
index e2dee315cde..e5985fb94da 100644
--- a/app/workers/concerns/gitlab/github_import/stage_methods.rb
+++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb
@@ -5,11 +5,17 @@ module Gitlab
module StageMethods
# project_id - The ID of the GitLab project to import the data into.
def perform(project_id)
+ info(project_id, message: 'starting stage')
+
return unless (project = find_project(project_id))
client = GithubImport.new_client_for(project)
try_import(client, project)
+
+ info(project_id, message: 'stage finished')
+ rescue => e
+ error(project_id, e)
end
# client - An instance of Gitlab::GithubImport::Client.
@@ -27,6 +33,39 @@ module Gitlab
Project.joins_import_state.where(import_state: { status: :started }).find_by(id: id)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def info(project_id, extra = {})
+ logger.info(log_attributes(project_id, extra))
+ end
+
+ def error(project_id, exception)
+ logger.error(
+ log_attributes(
+ project_id,
+ message: 'stage failed',
+ 'error.message': exception.message
+ )
+ )
+
+ Gitlab::ErrorTracking.track_and_raise_exception(
+ exception,
+ log_attributes(project_id)
+ )
+ end
+
+ def log_attributes(project_id, extra = {})
+ extra.merge(
+ import_source: :github,
+ project_id: project_id,
+ import_stage: self.class.name
+ )
+ end
+
+ def logger
+ @logger ||= Gitlab::Import::Logger.build
+ end
end
end
end
diff --git a/app/workers/concerns/limited_capacity/worker.rb b/app/workers/concerns/limited_capacity/worker.rb
index b5a97e49300..9dd8d942146 100644
--- a/app/workers/concerns/limited_capacity/worker.rb
+++ b/app/workers/concerns/limited_capacity/worker.rb
@@ -73,7 +73,7 @@ module LimitedCapacity
raise
ensure
job_tracker.remove(jid)
- report_prometheus_metrics
+ report_prometheus_metrics(*args)
re_enqueue(*args) unless exception
end
diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb
index 6f399b6d90b..641ca691868 100644
--- a/app/workers/concerns/reenqueuer.rb
+++ b/app/workers/concerns/reenqueuer.rb
@@ -37,6 +37,7 @@ module Reenqueuer
include ReenqueuerSleeper
sidekiq_options retry: false
+ deduplicate :none
end
def perform(*args)
@@ -52,7 +53,11 @@ module Reenqueuer
private
def reenqueue(*args)
- self.class.perform_async(*args) if yield
+ result = yield
+
+ self.class.perform_async(*args) if result
+
+ result
end
# Override as needed
diff --git a/app/workers/concerns/worker_context.rb b/app/workers/concerns/worker_context.rb
index f2ff3ecfb6b..6acb9acceeb 100644
--- a/app/workers/concerns/worker_context.rb
+++ b/app/workers/concerns/worker_context.rb
@@ -5,7 +5,7 @@ module WorkerContext
class_methods do
def worker_context(attributes)
- @worker_context = Gitlab::ApplicationContext.new(attributes)
+ @worker_context = Gitlab::ApplicationContext.new(**attributes)
end
def get_worker_context
@@ -60,6 +60,6 @@ module WorkerContext
end
def with_context(context, &block)
- Gitlab::ApplicationContext.new(context).use { yield(**context) }
+ Gitlab::ApplicationContext.new(**context).use { yield(**context) }
end
end
diff --git a/app/workers/create_evidence_worker.rb b/app/workers/create_evidence_worker.rb
deleted file mode 100644
index b18028e4114..00000000000
--- a/app/workers/create_evidence_worker.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- feature_category :release_evidence
- weight 2
-
- # pipeline_id is optional for backward compatibility with existing jobs
- # caller should always try to provide the pipeline and pass nil only
- # if pipeline is absent
- def perform(release_id, pipeline_id = nil)
- release = Release.find_by_id(release_id)
- return unless release
-
- pipeline = Ci::Pipeline.find_by_id(pipeline_id)
-
- ::Releases::CreateEvidenceService.new(release, pipeline: pipeline).execute
- end
-end
diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb
index 8a1709f04e1..06790cc89d9 100644
--- a/app/workers/create_note_diff_file_worker.rb
+++ b/app/workers/create_note_diff_file_worker.rb
@@ -3,7 +3,7 @@
class CreateNoteDiffFileWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- feature_category :source_code_management
+ feature_category :code_review
def perform(diff_note_id)
diff_note = DiffNote.find(diff_note_id)
diff --git a/app/workers/delete_diff_files_worker.rb b/app/workers/delete_diff_files_worker.rb
index a31cf650b83..289df8873ec 100644
--- a/app/workers/delete_diff_files_worker.rb
+++ b/app/workers/delete_diff_files_worker.rb
@@ -3,7 +3,7 @@
class DeleteDiffFilesWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- feature_category :source_code_management
+ feature_category :code_review
# rubocop: disable CodeReuse/ActiveRecord
def perform(merge_request_diff_id)
diff --git a/app/workers/environments/canary_ingress/update_worker.rb b/app/workers/environments/canary_ingress/update_worker.rb
new file mode 100644
index 00000000000..53cc38e9eec
--- /dev/null
+++ b/app/workers/environments/canary_ingress/update_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Environments
+ module CanaryIngress
+ class UpdateWorker
+ include ApplicationWorker
+
+ sidekiq_options retry: false
+ idempotent!
+ worker_has_external_dependencies!
+ feature_category :continuous_delivery
+
+ def perform(environment_id, params)
+ Environment.find_by_id(environment_id).try do |environment|
+ Environments::CanaryIngress::UpdateService
+ .new(environment.project, nil, params.with_indifferent_access)
+ .execute(environment)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/advance_stage_worker.rb b/app/workers/gitlab/github_import/advance_stage_worker.rb
index 834c2f7791c..af406b32415 100644
--- a/app/workers/gitlab/github_import/advance_stage_worker.rb
+++ b/app/workers/gitlab/github_import/advance_stage_worker.rb
@@ -16,6 +16,8 @@ module Gitlab
# The known importer stages and their corresponding Sidekiq workers.
STAGES = {
+ pull_requests_merged_by: Stage::ImportPullRequestsMergedByWorker,
+ pull_request_reviews: Stage::ImportPullRequestsReviewsWorker,
issues_and_diff_notes: Stage::ImportIssuesAndDiffNotesWorker,
notes: Stage::ImportNotesWorker,
lfs_objects: Stage::ImportLfsObjectsWorker,
diff --git a/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
new file mode 100644
index 00000000000..79ef917bbc5
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportPullRequestMergedByWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ def representation_class
+ Gitlab::GithubImport::Representation::PullRequest
+ end
+
+ def importer_class
+ Importer::PullRequestMergedByImporter
+ end
+
+ def counter_name
+ :github_importer_imported_pull_requests_merged_by
+ end
+
+ def counter_description
+ 'The number of imported GitHub pull requests merged by'
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
new file mode 100644
index 00000000000..b8516fb8670
--- /dev/null
+++ b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ class ImportPullRequestReviewWorker # rubocop:disable Scalability/IdempotentWorker
+ include ObjectImporter
+
+ def representation_class
+ Gitlab::GithubImport::Representation::PullRequestReview
+ end
+
+ def importer_class
+ Importer::PullRequestReviewImporter
+ end
+
+ def counter_name
+ :github_importer_imported_pull_request_reviews
+ end
+
+ def counter_description
+ 'The number of imported GitHub pull request reviews'
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/import_pull_request_worker.rb b/app/workers/gitlab/github_import/import_pull_request_worker.rb
index ec806ad170b..9560874f247 100644
--- a/app/workers/gitlab/github_import/import_pull_request_worker.rb
+++ b/app/workers/gitlab/github_import/import_pull_request_worker.rb
@@ -6,7 +6,7 @@ module Gitlab
include ObjectImporter
def representation_class
- Representation::PullRequest
+ Gitlab::GithubImport::Representation::PullRequest
end
def importer_class
diff --git a/app/workers/gitlab/github_import/stage/finish_import_worker.rb b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
index 73699a74a4a..058e1a0853d 100644
--- a/app/workers/gitlab/github_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/github_import/stage/finish_import_worker.rb
@@ -20,12 +20,15 @@ module Gitlab
def report_import_time(project)
duration = Time.zone.now - project.created_at
- path = project.full_path
- histogram.observe({ project: path }, duration)
+ histogram.observe({ project: project.full_path }, duration)
counter.increment
- logger.info("GitHub importer finished for #{path} in #{duration.round(2)} seconds")
+ info(
+ project.id,
+ message: "GitHub project import finished",
+ duration_s: duration.round(2)
+ )
end
def histogram
diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
index 11c2a2ac9b4..202bb335ca1 100644
--- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb
@@ -20,6 +20,7 @@ module Gitlab
# project - An instance of Project.
def import(client, project)
IMPORTERS.each do |klass|
+ info(project.id, message: "starting importer", importer: klass.name)
klass.new(project, client).execute
end
diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb
index 68b6e159fa4..486057804b4 100644
--- a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb
@@ -19,6 +19,7 @@ module Gitlab
# project - An instance of Project.
def import(client, project)
waiters = IMPORTERS.each_with_object({}) do |klass, hash|
+ info(project.id, message: "starting importer", importer: klass.name)
waiter = klass.new(project, client).execute
hash[waiter.key] = waiter.jobs_remaining
end
diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb
index a19df399969..de2a7f9fc29 100644
--- a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb
@@ -16,6 +16,8 @@ module Gitlab
# project - An instance of Project.
def import(project)
+ info(project.id, message: "starting importer", importer: 'Importer::LfsObjectsImporter')
+
waiter = Importer::LfsObjectsImporter
.new(project, nil)
.execute
diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb
index 49b9821cd45..e1da26a9d48 100644
--- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb
@@ -11,6 +11,7 @@ module Gitlab
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
+ info(project.id, message: "starting importer", importer: 'Importer::NotesImporter')
waiter = Importer::NotesImporter
.new(project, client)
.execute
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb
new file mode 100644
index 00000000000..3e15c346659
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportPullRequestsMergedByWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ waiter = Importer::PullRequestsMergedByImporter
+ .new(project, client)
+ .execute
+
+ project.import_state.refresh_jid_expiration
+
+ AdvanceStageWorker.perform_async(
+ project.id,
+ { waiter.key => waiter.jobs_remaining },
+ :pull_request_reviews
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb
new file mode 100644
index 00000000000..0809d0b7c29
--- /dev/null
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module GithubImport
+ module Stage
+ class ImportPullRequestsReviewsWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include GithubImport::Queue
+ include StageMethods
+
+ # client - An instance of Gitlab::GithubImport::Client.
+ # project - An instance of Project.
+ def import(client, project)
+ waiter =
+ if Feature.enabled?(:github_import_pull_request_reviews, project, default_enabled: true)
+ waiter = Importer::PullRequestsReviewsImporter
+ .new(project, client)
+ .execute
+
+ project.import_state.refresh_jid_expiration
+
+ waiter
+ else
+ JobWaiter.new
+ end
+
+ AdvanceStageWorker.perform_async(
+ project.id,
+ { waiter.key => waiter.jobs_remaining },
+ :issues_and_diff_notes
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
index 3299db5653b..bf2defa6326 100644
--- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb
@@ -11,6 +11,7 @@ module Gitlab
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
+ info(project.id, message: "starting importer", importer: 'Importer::PullRequestsImporter')
waiter = Importer::PullRequestsImporter
.new(project, client)
.execute
@@ -20,7 +21,7 @@ module Gitlab
AdvanceStageWorker.perform_async(
project.id,
{ waiter.key => waiter.jobs_remaining },
- :issues_and_diff_notes
+ :pull_requests_merged_by
)
end
end
diff --git a/app/workers/gitlab/github_import/stage/import_repository_worker.rb b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
index cb9ef1cd198..3338f7e58c0 100644
--- a/app/workers/gitlab/github_import/stage/import_repository_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_repository_worker.rb
@@ -21,6 +21,7 @@ module Gitlab
# expiration time.
RefreshImportJidWorker.perform_in_the_future(project.id, jid)
+ info(project.id, message: "starting importer", importer: 'Importer::RepositoryImporter')
importer = Importer::RepositoryImporter.new(project, client)
return unless importer.execute
diff --git a/app/workers/gitlab_performance_bar_stats_worker.rb b/app/workers/gitlab_performance_bar_stats_worker.rb
new file mode 100644
index 00000000000..d63f8111864
--- /dev/null
+++ b/app/workers/gitlab_performance_bar_stats_worker.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class GitlabPerformanceBarStatsWorker
+ include ApplicationWorker
+
+ LEASE_KEY = 'gitlab:performance_bar_stats'
+ LEASE_TIMEOUT = 600
+ WORKER_DELAY = 120
+ STATS_KEY = 'performance_bar_stats:pending_request_ids'
+
+ feature_category :metrics
+ idempotent!
+
+ def perform(lease_uuid)
+ Gitlab::Redis::SharedState.with do |redis|
+ request_ids = fetch_request_ids(redis, lease_uuid)
+ stats = Gitlab::PerformanceBar::Stats.new(redis)
+
+ request_ids.each do |id|
+ stats.process(id)
+ end
+ end
+ end
+
+ private
+
+ def fetch_request_ids(redis, lease_uuid)
+ ids = redis.smembers(STATS_KEY)
+ redis.del(STATS_KEY)
+ Gitlab::ExclusiveLease.cancel(LEASE_KEY, lease_uuid)
+
+ ids
+ end
+end
diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb
index a696c6e746a..1bb600bbd13 100644
--- a/app/workers/gitlab_usage_ping_worker.rb
+++ b/app/workers/gitlab_usage_ping_worker.rb
@@ -13,6 +13,10 @@ class GitlabUsagePingWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_retry_in { |count| (count + 1) * 8.hours.to_i }
def perform
+ # Disable usage ping for GitLab.com
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/292929 for details
+ return if Gitlab.com?
+
# Multiple Sidekiq workers could run this. We should only do this at most once a day.
in_lock(LEASE_KEY, ttl: LEASE_TIMEOUT) do
# Splay the request over a minute to avoid thundering herd problems.
diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb
index c7b5f8cd0a7..521e5b8fbc2 100644
--- a/app/workers/import_issues_csv_worker.rb
+++ b/app/workers/import_issues_csv_worker.rb
@@ -3,6 +3,7 @@
class ImportIssuesCsvWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
+ idempotent!
feature_category :issue_tracking
worker_resource_boundary :cpu
weight 2
@@ -12,13 +13,15 @@ class ImportIssuesCsvWorker # rubocop:disable Scalability/IdempotentWorker
end
def perform(current_user_id, project_id, upload_id)
- @user = User.find(current_user_id)
- @project = Project.find(project_id)
- @upload = Upload.find(upload_id)
+ user = User.find(current_user_id)
+ project = Project.find(project_id)
+ upload = Upload.find(upload_id)
- importer = Issues::ImportCsvService.new(@user, @project, @upload.retrieve_uploader)
+ importer = Issues::ImportCsvService.new(user, project, upload.retrieve_uploader)
importer.execute
- @upload.destroy
+ upload.destroy
+ rescue ActiveRecord::RecordNotFound
+ # Resources have been removed, job should not be retried
end
end
diff --git a/app/workers/jira_connect/sync_branch_worker.rb b/app/workers/jira_connect/sync_branch_worker.rb
index 4c1c987353d..d7e773b0861 100644
--- a/app/workers/jira_connect/sync_branch_worker.rb
+++ b/app/workers/jira_connect/sync_branch_worker.rb
@@ -7,6 +7,7 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
loggable_arguments 1, 2
+ worker_has_external_dependencies!
def perform(project_id, branch_name, commit_shas, update_sequence_id = nil)
project = Project.find_by_id(project_id)
diff --git a/app/workers/jira_connect/sync_builds_worker.rb b/app/workers/jira_connect/sync_builds_worker.rb
new file mode 100644
index 00000000000..c1c749f6041
--- /dev/null
+++ b/app/workers/jira_connect/sync_builds_worker.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module JiraConnect
+ class SyncBuildsWorker
+ include ApplicationWorker
+
+ idempotent!
+ worker_has_external_dependencies!
+
+ queue_namespace :jira_connect
+ feature_category :integrations
+
+ def perform(pipeline_id, sequence_id)
+ pipeline = Ci::Pipeline.find_by_id(pipeline_id)
+
+ return unless pipeline
+ return unless Feature.enabled?(:jira_sync_builds, pipeline.project)
+
+ ::JiraConnect::SyncService
+ .new(pipeline.project)
+ .execute(pipelines: [pipeline], update_sequence_id: sequence_id)
+ end
+ end
+end
diff --git a/app/workers/jira_connect/sync_merge_request_worker.rb b/app/workers/jira_connect/sync_merge_request_worker.rb
index f45ab38f35d..6ef426790b3 100644
--- a/app/workers/jira_connect/sync_merge_request_worker.rb
+++ b/app/workers/jira_connect/sync_merge_request_worker.rb
@@ -7,6 +7,8 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
+ worker_has_external_dependencies!
+
def perform(merge_request_id, update_sequence_id = nil)
merge_request = MergeRequest.find_by_id(merge_request_id)
diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb
index 50f583005c0..971d6abaa51 100644
--- a/app/workers/member_invitation_reminder_emails_worker.rb
+++ b/app/workers/member_invitation_reminder_emails_worker.rb
@@ -8,8 +8,6 @@ class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/Idempot
urgency :low
def perform
- return unless Gitlab::Experimentation.enabled?(:invitation_reminders)
-
Member.not_accepted_invitations.not_expired.last_ten_days_excluding_today.find_in_batches do |invitations|
invitations.each do |invitation|
Members::InvitationReminderEmailService.new(invitation).execute
diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb
index 37774658ba8..6b991a2253f 100644
--- a/app/workers/merge_request_cleanup_refs_worker.rb
+++ b/app/workers/merge_request_cleanup_refs_worker.rb
@@ -3,7 +3,7 @@
class MergeRequestCleanupRefsWorker
include ApplicationWorker
- feature_category :source_code_management
+ feature_category :code_review
idempotent!
def perform(merge_request_id)
diff --git a/app/workers/merge_request_mergeability_check_worker.rb b/app/workers/merge_request_mergeability_check_worker.rb
index 1a84efb4e52..70d5f49d70e 100644
--- a/app/workers/merge_request_mergeability_check_worker.rb
+++ b/app/workers/merge_request_mergeability_check_worker.rb
@@ -3,7 +3,7 @@
class MergeRequestMergeabilityCheckWorker
include ApplicationWorker
- feature_category :source_code_management
+ feature_category :code_review
idempotent!
def perform(merge_request_id)
diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb
index 0a95f40aa8f..3ef399bd9fc 100644
--- a/app/workers/migrate_external_diffs_worker.rb
+++ b/app/workers/migrate_external_diffs_worker.rb
@@ -3,7 +3,7 @@
class MigrateExternalDiffsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- feature_category :source_code_management
+ feature_category :code_review
def perform(merge_request_diff_id)
diff = MergeRequestDiff.find_by_id(merge_request_diff_id)
diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/namespaces/onboarding_user_added_worker.rb
new file mode 100644
index 00000000000..02608268d6f
--- /dev/null
+++ b/app/workers/namespaces/onboarding_user_added_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class OnboardingUserAddedWorker
+ include ApplicationWorker
+
+ feature_category :users
+ urgency :low
+
+ idempotent!
+
+ def perform(namespace_id)
+ namespace = Namespace.find(namespace_id)
+ OnboardingProgressService.new(namespace).execute(action: :user_added)
+ end
+ end
+end
diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb
index f672d37a83e..2d28561488b 100644
--- a/app/workers/new_merge_request_worker.rb
+++ b/app/workers/new_merge_request_worker.rb
@@ -4,7 +4,7 @@ class NewMergeRequestWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include NewIssuable
- feature_category :source_code_management
+ feature_category :code_review
urgency :high
worker_resource_boundary :cpu
weight 2
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index b114c67de47..8a9c166e5df 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Worker for updating any project specific caches.
-class ProjectCacheWorker # rubocop:disable Scalability/IdempotentWorker
+class ProjectCacheWorker
include ApplicationWorker
LEASE_TIMEOUT = 15.minutes.to_i
@@ -9,6 +9,7 @@ class ProjectCacheWorker # rubocop:disable Scalability/IdempotentWorker
feature_category :source_code_management
urgency :high
loggable_arguments 1, 2, 3
+ idempotent!
# project_id - The ID of the project for which to flush the cache.
# files - An Array containing extra types of files to refresh such as
diff --git a/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb b/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb
new file mode 100644
index 00000000000..4d2a6b47e3c
--- /dev/null
+++ b/app/workers/project_schedule_bulk_repository_shard_moves_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ProjectScheduleBulkRepositoryShardMovesWorker
+ include ApplicationWorker
+
+ idempotent!
+ feature_category :gitaly
+ urgency :throttled
+
+ def perform(source_storage_name, destination_storage_name = nil)
+ Projects::ScheduleBulkRepositoryShardMovesService.new.execute(source_storage_name, destination_storage_name)
+ end
+end
diff --git a/app/workers/purge_dependency_proxy_cache_worker.rb b/app/workers/purge_dependency_proxy_cache_worker.rb
index 594cdd3ed11..b4c88592543 100644
--- a/app/workers/purge_dependency_proxy_cache_worker.rb
+++ b/app/workers/purge_dependency_proxy_cache_worker.rb
@@ -15,6 +15,7 @@ class PurgeDependencyProxyCacheWorker
return unless valid?
@group.dependency_proxy_blobs.destroy_all # rubocop:disable Cop/DestroyAll
+ @group.dependency_proxy_manifests.destroy_all # rubocop:disable Cop/DestroyAll
end
private
diff --git a/app/workers/releases/create_evidence_worker.rb b/app/workers/releases/create_evidence_worker.rb
new file mode 100644
index 00000000000..db75fae1639
--- /dev/null
+++ b/app/workers/releases/create_evidence_worker.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Releases
+ class CreateEvidenceWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :release_evidence
+
+ # pipeline_id is optional for backward compatibility with existing jobs
+ # caller should always try to provide the pipeline and pass nil only
+ # if pipeline is absent
+ def perform(release_id, pipeline_id = nil)
+ release = Release.find_by_id(release_id)
+
+ return unless release
+
+ pipeline = Ci::Pipeline.find_by_id(pipeline_id)
+
+ ::Releases::CreateEvidenceService.new(release, pipeline: pipeline).execute
+ end
+ end
+end
diff --git a/app/workers/releases/manage_evidence_worker.rb b/app/workers/releases/manage_evidence_worker.rb
new file mode 100644
index 00000000000..8a925d22cea
--- /dev/null
+++ b/app/workers/releases/manage_evidence_worker.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Releases
+ class ManageEvidenceWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+ include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
+
+ feature_category :release_evidence
+
+ def perform
+ releases = Release.without_evidence.released_within_2hrs
+
+ releases.each do |release|
+ project = release.project
+ params = { tag: release.tag }
+
+ evidence_pipeline = Releases::EvidencePipelineFinder.new(project, params).execute
+
+ # perform_at released_at
+ ::Releases::CreateEvidenceWorker.perform_async(release.id, evidence_pipeline&.id)
+ end
+ end
+ end
+end
diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb
index 51fe60e25fc..90764d7374d 100644
--- a/app/workers/repository_import_worker.rb
+++ b/app/workers/repository_import_worker.rb
@@ -30,7 +30,7 @@ class RepositoryImportWorker # rubocop:disable Scalability/IdempotentWorker
return if service.async?
if result[:status] == :error
- fail_import(result[:message]) if template_import?
+ fail_import(result[:message])
raise result[:message]
end
diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb
index 21b5916f459..483aae84a3b 100644
--- a/app/workers/repository_update_remote_mirror_worker.rb
+++ b/app/workers/repository_update_remote_mirror_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RepositoryUpdateRemoteMirrorWorker # rubocop:disable Scalability/IdempotentWorker
+class RepositoryUpdateRemoteMirrorWorker
UpdateError = Class.new(StandardError)
include ApplicationWorker
@@ -11,6 +11,7 @@ class RepositoryUpdateRemoteMirrorWorker # rubocop:disable Scalability/Idempoten
sidekiq_options retry: 3, dead: false
feature_category :source_code_management
loggable_arguments 1
+ idempotent!
LOCK_WAIT_TIME = 30.seconds
MAX_TRIES = 3
diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
index 17cabba4278..59b8993f78f 100644
--- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb
+++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
@@ -4,7 +4,7 @@ class ScheduleMergeRequestCleanupRefsWorker
include ApplicationWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :source_code_management
+ feature_category :code_review
idempotent!
# Based on existing data, MergeRequestCleanupRefsWorker can run 3 jobs per
diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb
index 4e7b60c4ab7..70e4d56562b 100644
--- a/app/workers/schedule_migrate_external_diffs_worker.rb
+++ b/app/workers/schedule_migrate_external_diffs_worker.rb
@@ -10,7 +10,7 @@ class ScheduleMigrateExternalDiffsWorker # rubocop:disable Scalability/Idempoten
include Gitlab::ExclusiveLeaseHelpers
- feature_category :source_code_management
+ feature_category :code_review
def perform
in_lock(self.class.name.underscore, ttl: 2.hours, retries: 0) do
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
index 0f9b4ddb980..bea9d67b3e8 100644
--- a/app/workers/stuck_merge_jobs_worker.rb
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -4,7 +4,7 @@ class StuckMergeJobsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
- feature_category :source_code_management
+ feature_category :code_review
def self.logger
Gitlab::AppLogger
diff --git a/app/workers/trending_projects_worker.rb b/app/workers/trending_projects_worker.rb
index eb1a7f4fef9..5876cfb1fe7 100644
--- a/app/workers/trending_projects_worker.rb
+++ b/app/workers/trending_projects_worker.rb
@@ -2,10 +2,6 @@
class TrendingProjectsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- # rubocop:disable Scalability/CronWorkerContext
- # This worker does not perform work scoped to a context
- include CronjobQueue
- # rubocop:enable Scalability/CronWorkerContext
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :source_code_management
diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb
index 402c1777662..46cb32e7f08 100644
--- a/app/workers/update_merge_requests_worker.rb
+++ b/app/workers/update_merge_requests_worker.rb
@@ -3,7 +3,7 @@
class UpdateMergeRequestsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- feature_category :source_code_management
+ feature_category :code_review
urgency :high
worker_resource_boundary :cpu
weight 3