summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/learn_gitlab/code_owners_enabled.svg5
-rw-r--r--app/assets/images/learn_gitlab/git_write.svg16
-rw-r--r--app/assets/images/learn_gitlab/issue_created.svg65
-rw-r--r--app/assets/images/learn_gitlab/merge_request_created.svg107
-rw-r--r--app/assets/images/learn_gitlab/pipeline_created.svg38
-rw-r--r--app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg70
-rw-r--r--app/assets/images/learn_gitlab/security_scan_enabled.svg36
-rw-r--r--app/assets/images/learn_gitlab/trial_started.svg9
-rw-r--r--app/assets/images/learn_gitlab/user_added.svg4
-rw-r--r--app/assets/javascripts/alert_management/list.js1
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js5
-rw-r--r--app/assets/javascripts/analytics/usage_trends/index.js2
-rw-r--r--app/assets/javascripts/api.js8
-rw-r--r--app/assets/javascripts/api/projects_api.js8
-rw-r--r--app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue2
-rw-r--r--app/assets/javascripts/autosave.js2
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue11
-rw-r--r--app/assets/javascripts/batch_comments/components/draft_note.vue3
-rw-r--r--app/assets/javascripts/batch_comments/components/review_bar.vue10
-rw-r--r--app/assets/javascripts/batch_comments/constants.js2
-rw-r--r--app/assets/javascripts/batch_comments/index.js13
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/playable.js40
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/keybindings.js2
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue2
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js1
-rw-r--r--app/assets/javascripts/blob/notebook/index.js3
-rw-r--r--app/assets/javascripts/blob/notebook/notebook_viewer.vue6
-rw-r--r--app/assets/javascripts/blob/pipeline_tour_success_modal.vue4
-rw-r--r--app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue2
-rw-r--r--app/assets/javascripts/boards/boards_util.js12
-rw-r--r--app/assets/javascripts/boards/components/board_add_new_column.vue32
-rw-r--r--app/assets/javascripts/boards/components/board_app.vue29
-rw-r--r--app/assets/javascripts/boards/components/board_card_deprecated.vue61
-rw-r--r--app/assets/javascripts/boards/components/board_card_inner.vue9
-rw-r--r--app/assets/javascripts/boards/components/board_card_layout_deprecated.vue101
-rw-r--r--app/assets/javascripts/boards/components/board_column_deprecated.vue112
-rw-r--r--app/assets/javascripts/boards/components/board_content.vue31
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue19
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_list_deprecated.vue459
-rw-r--r--app/assets/javascripts/boards/components/board_list_header.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_list_header_deprecated.vue361
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue_deprecated.vue138
-rw-r--r--app/assets/javascripts/boards/components/board_settings_sidebar.vue53
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js115
-rw-r--r--app/assets/javascripts/boards/components/boards_selector_deprecated.vue360
-rw-r--r--app/assets/javascripts/boards/components/config_toggle.vue8
-rw-r--r--app/assets/javascripts/boards/components/issue_board_filtered_search.vue47
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue247
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue48
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js119
-rw-r--r--app/assets/javascripts/boards/components/project_select_deprecated.vue146
-rw-r--r--app/assets/javascripts/boards/config_toggle.js3
-rw-r--r--app/assets/javascripts/boards/constants.js5
-rw-r--r--app/assets/javascripts/boards/ee_functions.js4
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js15
-rw-r--r--app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql10
-rw-r--r--app/assets/javascripts/boards/graphql/issue.fragment.graphql1
-rw-r--r--app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql10
-rw-r--r--app/assets/javascripts/boards/graphql/project_milestones.query.graphql2
-rw-r--r--app/assets/javascripts/boards/index.js344
-rw-r--r--app/assets/javascripts/boards/models/assignee.js13
-rw-r--r--app/assets/javascripts/boards/models/issue.js99
-rw-r--r--app/assets/javascripts/boards/models/iteration.js9
-rw-r--r--app/assets/javascripts/boards/models/label.js11
-rw-r--r--app/assets/javascripts/boards/models/list.js182
-rw-r--r--app/assets/javascripts/boards/models/milestone.js15
-rw-r--r--app/assets/javascripts/boards/models/project.js7
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js15
-rw-r--r--app/assets/javascripts/boards/stores/actions.js74
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js883
-rw-r--r--app/assets/javascripts/boards/stores/boards_store_ee.js5
-rw-r--r--app/assets/javascripts/boards/stores/getters.js6
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js18
-rw-r--r--app/assets/javascripts/boards/stores/state.js2
-rw-r--r--app/assets/javascripts/captcha/init_recaptcha_script.js2
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue2
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js2
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js8
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue3
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue2
-rw-r--r--app/assets/javascripts/commit/image_file.js7
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js9
-rw-r--r--app/assets/javascripts/commit/pipelines/utils.js11
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue37
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue6
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue6
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue142
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue23
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue23
-rw-r--r--app/assets/javascripts/content_editor/constants.js4
-rw-r--r--app/assets/javascripts/content_editor/content_editor.stories.js27
-rw-r--r--app/assets/javascripts/content_editor/extensions/audio.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/blockquote.js34
-rw-r--r--app/assets/javascripts/content_editor/extensions/bullet_list.js20
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_block_highlight.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_item.js49
-rw-r--r--app/assets/javascripts/content_editor/extensions/description_list.js23
-rw-r--r--app/assets/javascripts/content_editor/extensions/division.js17
-rw-r--r--app/assets/javascripts/content_editor/extensions/emoji.js18
-rw-r--r--app/assets/javascripts/content_editor/extensions/figure.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/figure_caption.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/html_marks.js66
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js23
-rw-r--r--app/assets/javascripts/content_editor/extensions/inline_diff.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/link.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/ordered_list.js16
-rw-r--r--app/assets/javascripts/content_editor/extensions/playable.js66
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js41
-rw-r--r--app/assets/javascripts/content_editor/extensions/subscript.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/superscript.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_cell.js9
-rw-r--r--app/assets/javascripts/content_editor/extensions/table_header.js8
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_item.js6
-rw-r--r--app/assets/javascripts/content_editor/extensions/task_list.js28
-rw-r--r--app/assets/javascripts/content_editor/extensions/video.js10
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js16
-rw-r--r--app/assets/javascripts/content_editor/services/feature_flags.js3
-rw-r--r--app/assets/javascripts/content_editor/services/mark_utils.js17
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js163
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js40
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js345
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue74
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/constants.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/components/banner.vue54
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue57
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_table.vue13
-rw-r--r--app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue47
-rw-r--r--app/assets/javascripts/cycle_analytics/index.js14
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js15
-rw-r--r--app/assets/javascripts/cycle_analytics/store/getters.js17
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js18
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js3
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue63
-rw-r--r--app/assets/javascripts/deploy_freeze/store/actions.js16
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutations.js33
-rw-r--r--app/assets/javascripts/deprecated_jquery_dropdown/render.js2
-rw-r--r--app/assets/javascripts/deprecated_notes.js (renamed from app/assets/javascripts/notes.js)8
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue2
-rw-r--r--app/assets/javascripts/design_management/components/design_scaler.vue18
-rw-r--r--app/assets/javascripts/design_management/components/design_sidebar.vue2
-rw-r--r--app/assets/javascripts/design_management/components/image.vue22
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql7
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql6
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue12
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue3
-rw-r--r--app/assets/javascripts/design_management/utils/design_management_utils.js7
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js1
-rw-r--r--app/assets/javascripts/diffs/components/app.vue113
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue10
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue5
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue5
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue5
-rw-r--r--app/assets/javascripts/diffs/components/pre_renderer.vue1
-rw-r--r--app/assets/javascripts/diffs/constants.js5
-rw-r--r--app/assets/javascripts/diffs/index.js96
-rw-r--r--app/assets/javascripts/diffs/store/actions.js22
-rw-r--r--app/assets/javascripts/diffs/store/getters.js3
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js6
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js4
-rw-r--r--app/assets/javascripts/diffs/utils/preferences.js13
-rw-r--r--app/assets/javascripts/dropzone_input.js8
-rw-r--r--app/assets/javascripts/due_date_select.js191
-rw-r--r--app/assets/javascripts/emoji/index.js5
-rw-r--r--app/assets/javascripts/emoji/support/unicode_support_map.js2
-rw-r--r--app/assets/javascripts/environments/components/container.vue6
-rw-r--r--app/assets/javascripts/environments/components/edit_environment.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_form.vue15
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue50
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue41
-rw-r--r--app/assets/javascripts/environments/components/environments_detail_header.vue8
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue7
-rw-r--r--app/assets/javascripts/environments/constants.js2
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js3
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue5
-rw-r--r--app/assets/javascripts/environments/index.js4
-rw-r--r--app/assets/javascripts/environments/mount_show.js1
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue2
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue3
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutations.js4
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue79
-rw-r--r--app/assets/javascripts/error_tracking_settings/index.js11
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js4
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutations.js14
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/state.js1
-rw-r--r--app/assets/javascripts/error_tracking_settings/utils.js10
-rw-r--r--app/assets/javascripts/experimentation/utils.js25
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue2
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue2
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service.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.vue4
-rw-r--r--app/assets/javascripts/frequent_items/store/actions.js2
-rw-r--r--app/assets/javascripts/graphql_shared/constants.js4
-rw-r--r--app/assets/javascripts/groups/components/app.vue2
-rw-r--r--app/assets/javascripts/groups/components/groups.vue8
-rw-r--r--app/assets/javascripts/groups/components/invite_members_banner.vue18
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue11
-rw-r--r--app/assets/javascripts/groups/init_invite_members_banner.js13
-rw-r--r--app/assets/javascripts/header_search/components/app.vue83
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue42
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue31
-rw-r--r--app/assets/javascripts/header_search/constants.js17
-rw-r--r--app/assets/javascripts/header_search/index.js26
-rw-r--r--app/assets/javascripts/header_search/store/actions.js5
-rw-r--r--app/assets/javascripts/header_search/store/getters.js135
-rw-r--r--app/assets/javascripts/header_search/store/index.js18
-rw-r--r--app/assets/javascripts/header_search/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/header_search/store/mutations.js7
-rw-r--r--app/assets/javascripts/header_search/store/state.js8
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/success_message.vue3
-rw-r--r--app/assets/javascripts/ide/components/error_message.vue8
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue3
-rw-r--r--app/assets/javascripts/ide/services/terminals.js4
-rw-r--r--app/assets/javascripts/ide/utils.js5
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue69
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue53
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_table.vue76
-rw-r--r--app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue57
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js89
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql4
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql18
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/services/source_groups_manager.js24
-rw-r--r--app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql13
-rw-r--r--app/assets/javascripts/import_entities/import_groups/utils.js9
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue4
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/actions.js4
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/mutations.js17
-rw-r--r--app/assets/javascripts/incidents/list.js2
-rw-r--r--app/assets/javascripts/init_changes_dropdown.js12
-rw-r--r--app/assets/javascripts/init_deprecated_notes.js (renamed from app/assets/javascripts/init_notes.js)2
-rw-r--r--app/assets/javascripts/init_diff_stats_dropdown.js30
-rw-r--r--app/assets/javascripts/init_issuable_sidebar.js10
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue4
-rw-r--r--app/assets/javascripts/invite_members/components/import_a_project_modal.vue157
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue2
-rw-r--r--app/assets/javascripts/invite_members/components/project_select.vue143
-rw-r--r--app/assets/javascripts/invite_members/init_import_a_project_modal.js23
-rw-r--r--app/assets/javascripts/issuable/components/csv_export_modal.vue4
-rw-r--r--app/assets/javascripts/issuable_list/components/issuable_item.vue9
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue19
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue3
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue6
-rw-r--r--app/assets/javascripts/issues_list/components/issuable.vue2
-rw-r--r--app/assets/javascripts/issues_list/components/issues_list_app.vue131
-rw-r--r--app/assets/javascripts/issues_list/constants.js22
-rw-r--r--app/assets/javascripts/issues_list/index.js22
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues.query.graphql35
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql30
-rw-r--r--app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql105
-rw-r--r--app/assets/javascripts/issues_list/queries/issue.fragment.graphql1
-rw-r--r--app/assets/javascripts/issues_list/queries/iteration.fragment.graphql4
-rw-r--r--app/assets/javascripts/issues_list/queries/label.fragment.graphql6
-rw-r--r--app/assets/javascripts/issues_list/queries/milestone.fragment.graphql4
-rw-r--r--app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql8
-rw-r--r--app/assets/javascripts/issues_list/queries/search_iterations.query.graphql18
-rw-r--r--app/assets/javascripts/issues_list/queries/search_labels.query.graphql18
-rw-r--r--app/assets/javascripts/issues_list/queries/search_milestones.query.graphql16
-rw-r--r--app/assets/javascripts/issues_list/queries/search_users.query.graphql20
-rw-r--r--app/assets/javascripts/issues_list/queries/user.fragment.graphql6
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/utils.js4
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue8
-rw-r--r--app/assets/javascripts/jobs/components/table/cells/actions_cell.vue183
-rw-r--r--app/assets/javascripts/jobs/components/table/constants.js23
-rw-r--r--app/assets/javascripts/jobs/components/table/event_hub.js3
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql3
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql10
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/mutations/job_play.mutation.graphql10
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/mutations/job_retry.mutation.graphql10
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql10
-rw-r--r--app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql1
-rw-r--r--app/assets/javascripts/jobs/components/table/index.js5
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table.vue2
-rw-r--r--app/assets/javascripts/jobs/components/table/jobs_table_app.vue10
-rw-r--r--app/assets/javascripts/labels_select.js52
-rw-r--r--app/assets/javascripts/learn_gitlab/track_learn_gitlab.js10
-rw-r--r--app/assets/javascripts/lib/apollo/instrumentation_link.js29
-rw-r--r--app/assets/javascripts/lib/dompurify.js6
-rw-r--r--app/assets/javascripts/lib/graphql.js20
-rw-r--r--app/assets/javascripts/lib/logger/hello.js16
-rw-r--r--app/assets/javascripts/lib/logger/hello_deferred.js5
-rw-r--r--app/assets/javascripts/lib/logger/index.js6
-rw-r--r--app/assets/javascripts/lib/utils/accessor.js28
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js11
-rw-r--r--app/assets/javascripts/lib/utils/datetime/date_format_utility.js105
-rw-r--r--app/assets/javascripts/lib/utils/dom_utils.js12
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js9
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js4
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js37
-rw-r--r--app/assets/javascripts/main.js37
-rw-r--r--app/assets/javascripts/main_jh.js1
-rw-r--r--app/assets/javascripts/merge_request.js29
-rw-r--r--app/assets/javascripts/merge_request_tabs.js71
-rw-r--r--app/assets/javascripts/milestone_select.js118
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue3
-rw-r--r--app/assets/javascripts/milestones/stores/mutations.js4
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue3
-rw-r--r--app/assets/javascripts/mr_notes/index.js36
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue16
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue9
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue85
-rw-r--r--app/assets/javascripts/notes/components/comment_type_dropdown.vue114
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue7
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue7
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue3
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue3
-rw-r--r--app/assets/javascripts/notes/stores/actions.js1
-rw-r--r--app/assets/javascripts/packages/details/components/package_history.vue1
-rw-r--r--app/assets/javascripts/packages/shared/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue105
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue55
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue32
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue42
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue46
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue34
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue17
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue57
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue47
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue129
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue132
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue26
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/pages/list.js16
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue2
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue2
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js34
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/new/toggle_invite_members.js14
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/show/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue (renamed from app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue)0
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue116
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue2
-rw-r--r--app/assets/javascripts/pages/projects/learn_gitlab/index/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js13
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/show/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue98
-rw-r--r--app/assets/javascripts/pages/projects/new/index.js40
-rw-r--r--app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql14
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/show/index.js14
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue4
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue2
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/usage_quotas/index.js23
-rw-r--r--app/assets/javascripts/pages/projects/wikis/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/work_items/index/index.js3
-rw-r--r--app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue8
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue12
-rw-r--r--app/assets/javascripts/performance_bar/components/request_warning.vue9
-rw-r--r--app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue18
-rw-r--r--app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue12
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue4
-rw-r--r--app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue9
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue11
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue23
-rw-r--r--app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue7
-rw-r--r--app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue5
-rw-r--r--app/assets/javascripts/pipeline_editor/constants.js2
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/client/commit_sha.graphql3
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql11
-rw-r--r--app/assets/javascripts/pipeline_editor/graphql/resolvers.js9
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js15
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue61
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue14
-rw-r--r--app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/graph/accessors.js25
-rw-r--r--app/assets/javascripts/pipelines/components/graph/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue20
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue32
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue24
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js78
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue13
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js52
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue71
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js60
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_graph.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_test_details.js34
-rw-r--r--app/assets/javascripts/pipelines/utils.js52
-rw-r--r--app/assets/javascripts/popovers/components/popovers.vue21
-rw-r--r--app/assets/javascripts/project_select_combo_button.js2
-rw-r--r--app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue1
-rw-r--r--app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql1
-rw-r--r--app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js7
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue12
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/index.js2
-rw-r--r--app/assets/javascripts/projects/project_new.js22
-rw-r--r--app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue5
-rw-r--r--app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue54
-rw-r--r--app/assets/javascripts/projects/storage_counter/components/app.vue106
-rw-r--r--app/assets/javascripts/projects/storage_counter/components/storage_table.vue78
-rw-r--r--app/assets/javascripts/projects/storage_counter/constants.js61
-rw-r--r--app/assets/javascripts/projects/storage_counter/index.js51
-rw-r--r--app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql16
-rw-r--r--app/assets/javascripts/projects/storage_counter/utils.js40
-rw-r--r--app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue53
-rw-r--r--app/assets/javascripts/projects/terraform_notification/constants.js3
-rw-r--r--app/assets/javascripts/projects/terraform_notification/index.js14
-rw-r--r--app/assets/javascripts/prometheus_alerts/components/reset_key.vue3
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js2
-rw-r--r--app/assets/javascripts/ref/components/ref_selector.vue3
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue6
-rw-r--r--app/assets/javascripts/reports/components/issue_body.js16
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue6
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue6
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue19
-rw-r--r--app/assets/javascripts/repository/components/blob_viewers/index.js6
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue10
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue7
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue4
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue7
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue21
-rw-r--r--app/assets/javascripts/repository/constants.js2
-rw-r--r--app/assets/javascripts/repository/mixins/preload.js3
-rw-r--r--app/assets/javascripts/repository/pages/blob.vue15
-rw-r--r--app/assets/javascripts/rest_api.js2
-rw-r--r--app/assets/javascripts/right_sidebar.js7
-rw-r--r--app/assets/javascripts/runner/admin_runners/admin_runners_app.vue32
-rw-r--r--app/assets/javascripts/runner/components/runner_filtered_search_bar.vue88
-rw-r--r--app/assets/javascripts/runner/components/runner_update_form.vue2
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/status_token_config.js32
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/tag_token.vue1
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/tag_token_config.js12
-rw-r--r--app/assets/javascripts/runner/components/search_tokens/type_token_config.js20
-rw-r--r--app/assets/javascripts/runner/constants.js6
-rw-r--r--app/assets/javascripts/runner/graphql/get_group_runners.query.graphql35
-rw-r--r--app/assets/javascripts/runner/group_runners/group_runners_app.vue137
-rw-r--r--app/assets/javascripts/runner/group_runners/index.js11
-rw-r--r--app/assets/javascripts/runner/runner_search_utils.js1
-rw-r--r--app/assets/javascripts/search/highlight_blob_search_result.js2
-rw-r--r--app/assets/javascripts/search/store/actions.js2
-rw-r--r--app/assets/javascripts/search/store/utils.js4
-rw-r--r--app/assets/javascripts/security_configuration/components/constants.js15
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js1
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue5
-rw-r--r--app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue6
-rw-r--r--app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/sidebar_editable_item.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue3
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js58
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js5
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js6
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js17
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js14
-rw-r--r--app/assets/javascripts/sidebar/track_invite_members.js6
-rw-r--r--app/assets/javascripts/snippet/snippet_show.js4
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_view.vue6
-rw-r--r--app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js2
-rw-r--r--app/assets/javascripts/tracking/constants.js4
-rw-r--r--app/assets/javascripts/tracking/index.js7
-rw-r--r--app/assets/javascripts/tracking/tracking.js40
-rw-r--r--app/assets/javascripts/tracking/utils.js24
-rw-r--r--app/assets/javascripts/user_popovers.js4
-rw-r--r--app/assets/javascripts/users_select/index.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue75
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue49
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js2
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue159
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js17
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js11
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue24
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue15
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/registry/title_area.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/settings/settings_block.vue84
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue132
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue145
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue83
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue278
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js52
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js50
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js28
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js38
-rw-r--r--app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue148
-rw-r--r--app/assets/javascripts/vue_shared/components/timezone_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue23
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/constants.js1
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js2
-rw-r--r--app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js2
-rw-r--r--app/assets/javascripts/webpack_non_compiled_placeholder.js22
-rw-r--r--app/assets/javascripts/whats_new/components/feature.vue6
-rw-r--r--app/assets/javascripts/work_items/components/app.vue9
-rw-r--r--app/assets/javascripts/work_items/graphql/typedefs.graphql0
-rw-r--r--app/assets/javascripts/work_items/index.js13
-rw-r--r--app/assets/stylesheets/application_dark.scss11
-rw-r--r--app/assets/stylesheets/components/content_editor.scss20
-rw-r--r--app/assets/stylesheets/errors.scss1
-rw-r--r--app/assets/stylesheets/framework/blocks.scss8
-rw-r--r--app/assets/stylesheets/framework/diffs.scss43
-rw-r--r--app/assets/stylesheets/framework/filters.scss9
-rw-r--r--app/assets/stylesheets/framework/icons.scss1
-rw-r--r--app/assets/stylesheets/framework/typography.scss21
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/mailer.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/_pipeline_mixins.scss12
-rw-r--r--app/assets/stylesheets/page_bundles/boards.scss15
-rw-r--r--app/assets/stylesheets/page_bundles/cycle_analytics.scss289
-rw-r--r--app/assets/stylesheets/page_bundles/escalation_policies.scss25
-rw-r--r--app/assets/stylesheets/page_bundles/merge_requests.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/new_namespace.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/pipeline.scss310
-rw-r--r--app/assets/stylesheets/page_bundles/reports.scss5
-rw-r--r--app/assets/stylesheets/page_bundles/signup.scss30
-rw-r--r--app/assets/stylesheets/pages/commits.scss4
-rw-r--r--app/assets/stylesheets/pages/issuable.scss5
-rw-r--r--app/assets/stylesheets/pages/login.scss5
-rw-r--r--app/assets/stylesheets/pages/note_form.scss2
-rw-r--r--app/assets/stylesheets/pages/profile.scss8
-rw-r--r--app/assets/stylesheets/pages/search.scss19
-rw-r--r--app/assets/stylesheets/pages/tree.scss1
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss85
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss35
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss10
-rw-r--r--app/assets/stylesheets/themes/_dark.scss28
-rw-r--r--app/assets/stylesheets/themes/theme_helper.scss30
-rw-r--r--app/assets/stylesheets/themes/theme_light.scss10
-rw-r--r--app/assets/stylesheets/utilities.scss25
-rw-r--r--app/controllers/admin/applications_controller.rb16
-rw-r--r--app/controllers/admin/background_migrations_controller.rb9
-rw-r--r--app/controllers/admin/runner_projects_controller.rb4
-rw-r--r--app/controllers/admin/users_controller.rb2
-rw-r--r--app/controllers/application_controller.rb6
-rw-r--r--app/controllers/boards/issues_controller.rb4
-rw-r--r--app/controllers/concerns/integrations/params.rb7
-rw-r--r--app/controllers/concerns/issuable_collections.rb8
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb4
-rw-r--r--app/controllers/concerns/oauth_applications.rb10
-rw-r--r--app/controllers/concerns/project_unauthorized.rb2
-rw-r--r--app/controllers/concerns/renders_projects_list.rb3
-rw-r--r--app/controllers/concerns/routable_actions.rb8
-rw-r--r--app/controllers/concerns/sessionless_authentication.rb1
-rw-r--r--app/controllers/dashboard/projects_controller.rb2
-rw-r--r--app/controllers/explore/projects_controller.rb7
-rw-r--r--app/controllers/groups/application_controller.rb2
-rw-r--r--app/controllers/groups/boards_controller.rb1
-rw-r--r--app/controllers/groups/clusters/integrations_controller.rb2
-rw-r--r--app/controllers/groups/clusters_controller.rb2
-rw-r--r--app/controllers/groups/runners_controller.rb4
-rw-r--r--app/controllers/groups/settings/applications_controller.rb15
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb16
-rw-r--r--app/controllers/import/bitbucket_controller.rb6
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb14
-rw-r--r--app/controllers/import/bulk_imports_controller.rb2
-rw-r--r--app/controllers/import/fogbugz_controller.rb8
-rw-r--r--app/controllers/import/github_controller.rb4
-rw-r--r--app/controllers/import/gitlab_controller.rb8
-rw-r--r--app/controllers/import/manifest_controller.rb14
-rw-r--r--app/controllers/invites_controller.rb7
-rw-r--r--app/controllers/jira_connect/installations_controller.rb31
-rw-r--r--app/controllers/members/mailgun/permanent_failures_controller.rb65
-rw-r--r--app/controllers/oauth/applications_controller.rb12
-rw-r--r--app/controllers/profiles/groups_controller.rb2
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb13
-rw-r--r--app/controllers/profiles_controller.rb12
-rw-r--r--app/controllers/projects/analytics/cycle_analytics/summary_controller.rb2
-rw-r--r--app/controllers/projects/application_controller.rb2
-rw-r--r--app/controllers/projects/boards_controller.rb1
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb1
-rw-r--r--app/controllers/projects/clusters/integrations_controller.rb2
-rw-r--r--app/controllers/projects/clusters_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb8
-rw-r--r--app/controllers/projects/feature_flags_controller.rb15
-rw-r--r--app/controllers/projects/issues_controller.rb11
-rw-r--r--app/controllers/projects/learn_gitlab_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb14
-rw-r--r--app/controllers/projects/packages/packages_controller.rb2
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb4
-rw-r--r--app/controllers/projects/pipelines_controller.rb9
-rw-r--r--app/controllers/projects/runner_projects_controller.rb4
-rw-r--r--app/controllers/projects/service_desk_controller.rb33
-rw-r--r--app/controllers/projects/services_controller.rb20
-rw-r--r--app/controllers/projects/settings/operations_controller.rb1
-rw-r--r--app/controllers/projects/tree_controller.rb4
-rw-r--r--app/controllers/projects/usage_quotas_controller.rb28
-rw-r--r--app/controllers/projects/wikis_controller.rb4
-rw-r--r--app/controllers/projects/work_items_controller.rb13
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/controllers/registrations/experience_levels_controller.rb44
-rw-r--r--app/controllers/registrations/welcome_controller.rb2
-rw-r--r--app/controllers/registrations_controller.rb10
-rw-r--r--app/controllers/search_controller.rb25
-rw-r--r--app/controllers/user_callouts_controller.rb10
-rw-r--r--app/controllers/users/group_callouts_controller.rb17
-rw-r--r--app/controllers/users/terms_controller.rb2
-rw-r--r--app/controllers/users_controller.rb2
-rw-r--r--app/experiments/combined_registration_experiment.rb23
-rw-r--r--app/experiments/security_reports_mr_widget_prompt_experiment.rb14
-rw-r--r--app/experiments/templates/new_project_readme_content/readme_advanced.md.tt2
-rw-r--r--app/finders/branches_finder.rb4
-rw-r--r--app/finders/ci/pipelines_finder.rb15
-rw-r--r--app/finders/ci/pipelines_for_merge_request_finder.rb24
-rw-r--r--app/finders/ci/runners_finder.rb18
-rw-r--r--app/finders/error_tracking/errors_finder.rb14
-rw-r--r--app/finders/groups/user_groups_finder.rb60
-rw-r--r--app/finders/issuable_finder.rb63
-rw-r--r--app/finders/issuable_finder/params.rb32
-rw-r--r--app/finders/issuables/label_filter.rb155
-rw-r--r--app/finders/issues_finder.rb7
-rw-r--r--app/finders/issues_finder/params.rb4
-rw-r--r--app/finders/packages/helm/package_files_finder.rb2
-rw-r--r--app/finders/packages/helm/packages_finder.rb30
-rw-r--r--app/finders/packages/npm/package_finder.rb17
-rw-r--r--app/finders/projects_finder.rb10
-rw-r--r--app/finders/repositories/tree_finder.rb61
-rw-r--r--app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb9
-rw-r--r--app/graphql/mutations/custom_emoji/destroy.rb36
-rw-r--r--app/graphql/mutations/customer_relations/organizations/create.rb53
-rw-r--r--app/graphql/mutations/customer_relations/organizations/update.rb52
-rw-r--r--app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb54
-rw-r--r--app/graphql/queries/repository/files.query.graphql1
-rw-r--r--app/graphql/queries/repository/paginated_tree.query.graphql54
-rw-r--r--app/graphql/queries/repository/path_last_commit.query.graphql1
-rw-r--r--app/graphql/queries/repository/permissions.query.graphql1
-rw-r--r--app/graphql/resolvers/base_resolver.rb10
-rw-r--r--app/graphql/resolvers/board_list_issues_resolver.rb18
-rw-r--r--app/graphql/resolvers/ci/group_runners_resolver.rb26
-rw-r--r--app/graphql/resolvers/ci/runners_resolver.rb15
-rw-r--r--app/graphql/resolvers/concerns/issue_resolver_arguments.rb5
-rw-r--r--app/graphql/resolvers/concerns/resolves_pipelines.rb7
-rw-r--r--app/graphql/resolvers/labels_resolver.rb2
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb2
-rw-r--r--app/graphql/resolvers/milestones_resolver.rb6
-rw-r--r--app/graphql/resolvers/package_details_resolver.rb2
-rw-r--r--app/graphql/resolvers/paginated_tree_resolver.rb4
-rw-r--r--app/graphql/resolvers/release_resolver.rb2
-rw-r--r--app/graphql/resolvers/repository_branch_names_resolver.rb6
-rw-r--r--app/graphql/resolvers/snippets_resolver.rb6
-rw-r--r--app/graphql/resolvers/todo_resolver.rb19
-rw-r--r--app/graphql/resolvers/tree_resolver.rb4
-rw-r--r--app/graphql/resolvers/users/groups_resolver.rb44
-rw-r--r--app/graphql/resolvers/users/snippets_resolver.rb2
-rw-r--r--app/graphql/types/admin/analytics/usage_trends/measurement_type.rb4
-rw-r--r--app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb4
-rw-r--r--app/graphql/types/alert_management/alert_type.rb8
-rw-r--r--app/graphql/types/award_emojis/award_emoji_type.rb12
-rw-r--r--app/graphql/types/base_field.rb1
-rw-r--r--app/graphql/types/boards/board_issuable_input_base_type.rb2
-rw-r--r--app/graphql/types/boards/board_issue_input_base_type.rb4
-rw-r--r--app/graphql/types/ci/config/job_restriction_type.rb2
-rw-r--r--app/graphql/types/ci/config/status_enum.rb4
-rw-r--r--app/graphql/types/ci/job_type.rb3
-rw-r--r--app/graphql/types/ci/pipeline_type.rb2
-rw-r--r--app/graphql/types/ci/runner_membership_filter_enum.rb18
-rw-r--r--app/graphql/types/ci/stage_type.rb25
-rw-r--r--app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb6
-rw-r--r--app/graphql/types/commit_action_type.rb2
-rw-r--r--app/graphql/types/container_repository_cleanup_status_enum.rb8
-rw-r--r--app/graphql/types/container_repository_tag_type.rb2
-rw-r--r--app/graphql/types/container_repository_type.rb2
-rw-r--r--app/graphql/types/custom_emoji_type.rb6
-rw-r--r--app/graphql/types/customer_relations/contact_type.rb55
-rw-r--r--app/graphql/types/customer_relations/organization_type.rb41
-rw-r--r--app/graphql/types/dependency_proxy/blob_type.rb16
-rw-r--r--app/graphql/types/dependency_proxy/group_setting_type.rb13
-rw-r--r--app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb16
-rw-r--r--app/graphql/types/dependency_proxy/manifest_type.rb22
-rw-r--r--app/graphql/types/design_management/design_at_version_type.rb4
-rw-r--r--app/graphql/types/design_management/design_fields.rb16
-rw-r--r--app/graphql/types/diff_paths_input_type.rb4
-rw-r--r--app/graphql/types/environment_type.rb4
-rw-r--r--app/graphql/types/eventable_type.rb2
-rw-r--r--app/graphql/types/group_type.rb73
-rw-r--r--app/graphql/types/issue_sort_enum.rb2
-rw-r--r--app/graphql/types/issue_type.rb7
-rw-r--r--app/graphql/types/issues/negated_issue_filter_input_type.rb6
-rw-r--r--app/graphql/types/merge_request_type.rb6
-rw-r--r--app/graphql/types/metadata/kas_type.rb2
-rw-r--r--app/graphql/types/milestone_wildcard_id_enum.rb6
-rw-r--r--app/graphql/types/mutation_type.rb10
-rw-r--r--app/graphql/types/namespace_type.rb2
-rw-r--r--app/graphql/types/negated_milestone_wildcard_id_enum.rb4
-rw-r--r--app/graphql/types/notes/note_type.rb4
-rw-r--r--app/graphql/types/notes/position_type_enum.rb2
-rw-r--r--app/graphql/types/packages/composer/json_type.rb8
-rw-r--r--app/graphql/types/packages/package_details_type.rb2
-rw-r--r--app/graphql/types/packages/package_file_type.rb4
-rw-r--r--app/graphql/types/packages/package_tag_type.rb8
-rw-r--r--app/graphql/types/packages/package_type.rb2
-rw-r--r--app/graphql/types/permission_types/custom_emoji.rb2
-rw-r--r--app/graphql/types/permission_types/group.rb2
-rw-r--r--app/graphql/types/permission_types/group_enum.rb12
-rw-r--r--app/graphql/types/project_statistics_type.rb2
-rw-r--r--app/graphql/types/project_type.rb10
-rw-r--r--app/graphql/types/prometheus_alert_type.rb2
-rw-r--r--app/graphql/types/query_type.rb12
-rw-r--r--app/graphql/types/range_input_type.rb4
-rw-r--r--app/graphql/types/release_asset_link_shared_input_arguments.rb2
-rw-r--r--app/graphql/types/release_assets_input_type.rb2
-rw-r--r--app/graphql/types/release_type.rb2
-rw-r--r--app/graphql/types/repository/blob_type.rb6
-rw-r--r--app/graphql/types/root_storage_statistics_type.rb18
-rw-r--r--app/graphql/types/snippet_type.rb4
-rw-r--r--app/graphql/types/snippets/blob_type.rb2
-rw-r--r--app/graphql/types/snippets/visibility_scopes_enum.rb6
-rw-r--r--app/graphql/types/terraform/state_type.rb4
-rw-r--r--app/graphql/types/terraform/state_version_type.rb5
-rw-r--r--app/graphql/types/timelog_type.rb12
-rw-r--r--app/graphql/types/todo_state_enum.rb4
-rw-r--r--app/graphql/types/todo_target_enum.rb10
-rw-r--r--app/graphql/types/todo_type.rb4
-rw-r--r--app/graphql/types/user_interface.rb10
-rw-r--r--app/graphql/types/user_merge_request_interaction_type.rb2
-rw-r--r--app/graphql/types/user_state_enum.rb6
-rw-r--r--app/helpers/analytics/cycle_analytics_helper.rb18
-rw-r--r--app/helpers/application_settings_helper.rb30
-rw-r--r--app/helpers/blob_helper.rb4
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb1
-rw-r--r--app/helpers/ci/runners_helper.rb9
-rw-r--r--app/helpers/diff_helper.rb21
-rw-r--r--app/helpers/environment_helper.rb1
-rw-r--r--app/helpers/gitlab_routing_helper.rb1
-rw-r--r--app/helpers/groups_helper.rb87
-rw-r--r--app/helpers/invite_members_helper.rb8
-rw-r--r--app/helpers/issuables_helper.rb29
-rw-r--r--app/helpers/issues_helper.rb51
-rw-r--r--app/helpers/learn_gitlab_helper.rb18
-rw-r--r--app/helpers/nav/new_dropdown_helper.rb18
-rw-r--r--app/helpers/nav/top_nav_helper.rb4
-rw-r--r--app/helpers/notify_helper.rb17
-rw-r--r--app/helpers/packages_helper.rb3
-rw-r--r--app/helpers/profiles_helper.rb20
-rw-r--r--app/helpers/projects_helper.rb2
-rw-r--r--app/helpers/recaptcha_helper.rb2
-rw-r--r--app/helpers/routing/pseudonymization_helper.rb58
-rw-r--r--app/helpers/search_helper.rb4
-rw-r--r--app/helpers/sessions_helper.rb16
-rw-r--r--app/helpers/sidebars_helper.rb3
-rw-r--r--app/helpers/sorting_helper.rb9
-rw-r--r--app/helpers/sorting_titles_values_helper.rb12
-rw-r--r--app/helpers/system_note_helper.rb3
-rw-r--r--app/helpers/user_callouts_helper.rb49
-rw-r--r--app/mailers/emails/admin_notification.rb4
-rw-r--r--app/mailers/emails/members.rb35
-rw-r--r--app/mailers/emails/profile.rb20
-rw-r--r--app/models/analytics/cycle_analytics/issue_stage_event.rb11
-rw-r--r--app/models/analytics/cycle_analytics/merge_request_stage_event.rb11
-rw-r--r--app/models/application_record.rb36
-rw-r--r--app/models/application_setting.rb102
-rw-r--r--app/models/application_setting_implementation.rb17
-rw-r--r--app/models/award_emoji.rb2
-rw-r--r--app/models/bulk_imports/entity.rb24
-rw-r--r--app/models/bulk_imports/tracker.rb4
-rw-r--r--app/models/ci/application_record.rb1
-rw-r--r--app/models/ci/bridge.rb18
-rw-r--r--app/models/ci/build.rb43
-rw-r--r--app/models/ci/build_trace_chunks/fog.rb10
-rw-r--r--app/models/ci/build_trace_metadata.rb45
-rw-r--r--app/models/ci/job_artifact.rb15
-rw-r--r--app/models/ci/pending_build.rb82
-rw-r--r--app/models/ci/pipeline.rb29
-rw-r--r--app/models/ci/pipeline_variable.rb2
-rw-r--r--app/models/ci/runner.rb40
-rw-r--r--app/models/ci/sources/pipeline.rb3
-rw-r--r--app/models/clusters/agent.rb6
-rw-r--r--app/models/clusters/agents/group_authorization.rb16
-rw-r--r--app/models/clusters/agents/implicit_authorization.rb20
-rw-r--r--app/models/clusters/agents/project_authorization.rb14
-rw-r--r--app/models/clusters/cluster.rb1
-rw-r--r--app/models/clusters/clusters_hierarchy.rb2
-rw-r--r--app/models/clusters/platforms/kubernetes.rb16
-rw-r--r--app/models/commit_status.rb10
-rw-r--r--app/models/compare.rb10
-rw-r--r--app/models/concerns/approvable_base.rb4
-rw-r--r--app/models/concerns/cache_markdown_field.rb33
-rw-r--r--app/models/concerns/calloutable.rb15
-rw-r--r--app/models/concerns/ci/contextable.rb4
-rw-r--r--app/models/concerns/cron_schedulable.rb8
-rw-r--r--app/models/concerns/enums/ci/commit_status.rb1
-rw-r--r--app/models/concerns/featurable.rb21
-rw-r--r--app/models/concerns/has_repository.rb4
-rw-r--r--app/models/concerns/integrations/has_data_fields.rb1
-rw-r--r--app/models/concerns/issuable.rb17
-rw-r--r--app/models/concerns/loose_foreign_key.rb95
-rw-r--r--app/models/concerns/mentionable.rb11
-rw-r--r--app/models/concerns/optimized_issuable_label_filter.rb121
-rw-r--r--app/models/concerns/partitioned_table.rb2
-rw-r--r--app/models/concerns/relative_positioning.rb12
-rw-r--r--app/models/concerns/sanitizable.rb52
-rw-r--r--app/models/concerns/sortable_title.rb21
-rw-r--r--app/models/concerns/taggable_queries.rb10
-rw-r--r--app/models/customer_relations/contact.rb33
-rw-r--r--app/models/customer_relations/organization.rb10
-rw-r--r--app/models/dependency_proxy/blob.rb3
-rw-r--r--app/models/dependency_proxy/image_ttl_group_policy.rb11
-rw-r--r--app/models/dependency_proxy/manifest.rb3
-rw-r--r--app/models/deploy_keys_project.rb1
-rw-r--r--app/models/design_management/action.rb2
-rw-r--r--app/models/diff_note.rb28
-rw-r--r--app/models/environment.rb73
-rw-r--r--app/models/environment_status.rb10
-rw-r--r--app/models/error_tracking/client_key.rb6
-rw-r--r--app/models/error_tracking/error.rb25
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb2
-rw-r--r--app/models/event.rb3
-rw-r--r--app/models/group.rb50
-rw-r--r--app/models/hooks/web_hook.rb2
-rw-r--r--app/models/instance_configuration.rb17
-rw-r--r--app/models/integration.rb6
-rw-r--r--app/models/integrations/base_chat_notification.rb2
-rw-r--r--app/models/integrations/datadog.rb2
-rw-r--r--app/models/integrations/prometheus.rb2
-rw-r--r--app/models/integrations/slack_slash_commands.rb2
-rw-r--r--app/models/integrations/zentao.rb78
-rw-r--r--app/models/integrations/zentao_tracker_data.rb23
-rw-r--r--app/models/internal_id.rb209
-rw-r--r--app/models/issue.rb23
-rw-r--r--app/models/issue/metrics.rb39
-rw-r--r--app/models/loose_foreign_keys.rb7
-rw-r--r--app/models/loose_foreign_keys/deleted_record.rb49
-rw-r--r--app/models/member.rb7
-rw-r--r--app/models/members/project_member.rb2
-rw-r--r--app/models/merge_request.rb79
-rw-r--r--app/models/merge_request/metrics.rb19
-rw-r--r--app/models/merge_request_diff.rb17
-rw-r--r--app/models/milestone.rb30
-rw-r--r--app/models/namespace.rb60
-rw-r--r--app/models/namespace_setting.rb14
-rw-r--r--app/models/namespaces/project_namespace.rb13
-rw-r--r--app/models/namespaces/traversal/linear.rb5
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb51
-rw-r--r--app/models/namespaces/traversal/recursive.rb8
-rw-r--r--app/models/namespaces/traversal/recursive_scopes.rb16
-rw-r--r--app/models/namespaces/user_namespace.rb11
-rw-r--r--app/models/note.rb17
-rw-r--r--app/models/onboarding_progress.rb2
-rw-r--r--app/models/operations/feature_flag.rb6
-rw-r--r--app/models/operations/feature_flag_scope.rb4
-rw-r--r--app/models/packages/package.rb9
-rw-r--r--app/models/packages/package_file.rb22
-rw-r--r--app/models/pages_deployment.rb7
-rw-r--r--app/models/postgresql/detached_partition.rb2
-rw-r--r--app/models/preloaders/commit_status_preloader.rb29
-rw-r--r--app/models/preloaders/merge_requests_preloader.rb19
-rw-r--r--app/models/preloaders/user_max_access_level_in_groups_preloader.rb27
-rw-r--r--app/models/project.rb137
-rw-r--r--app/models/project_feature.rb16
-rw-r--r--app/models/project_team.rb2
-rw-r--r--app/models/projects/project_topic.rb8
-rw-r--r--app/models/projects/topic.rb10
-rw-r--r--app/models/protected_branch.rb4
-rw-r--r--app/models/push_event_payload.rb3
-rw-r--r--app/models/release.rb1
-rw-r--r--app/models/repository.rb62
-rw-r--r--app/models/service_desk_setting.rb12
-rw-r--r--app/models/shard.rb6
-rw-r--r--app/models/user.rb181
-rw-r--r--app/models/user_callout.rb9
-rw-r--r--app/models/user_detail.rb17
-rw-r--r--app/models/user_preference.rb2
-rw-r--r--app/models/users/group_callout.rb25
-rw-r--r--app/models/work_item/type.rb24
-rw-r--r--app/models/zoom_meeting.rb2
-rw-r--r--app/policies/ci/runner_policy.rb6
-rw-r--r--app/policies/custom_emoji_policy.rb10
-rw-r--r--app/policies/customer_relations/contact_policy.rb6
-rw-r--r--app/policies/customer_relations/organization_policy.rb6
-rw-r--r--app/policies/dependency_proxy/blob_policy.rb6
-rw-r--r--app/policies/dependency_proxy/group_setting_policy.rb6
-rw-r--r--app/policies/dependency_proxy/image_ttl_group_policy_policy.rb6
-rw-r--r--app/policies/dependency_proxy/manifest_policy.rb6
-rw-r--r--app/policies/group_policy.rb15
-rw-r--r--app/policies/issue_policy.rb8
-rw-r--r--app/policies/user_policy.rb1
-rw-r--r--app/presenters/ci/build_presenter.rb8
-rw-r--r--app/presenters/ci/build_runner_presenter.rb2
-rw-r--r--app/presenters/ci/legacy_stage_presenter.rb13
-rw-r--r--app/presenters/ci/stage_presenter.rb15
-rw-r--r--app/presenters/commit_status_presenter.rb3
-rw-r--r--app/presenters/packages/helm/index_presenter.rb23
-rw-r--r--app/presenters/packages/npm/package_presenter.rb23
-rw-r--r--app/presenters/project_presenter.rb18
-rw-r--r--app/presenters/projects/import_export/project_export_presenter.rb1
-rw-r--r--app/presenters/snippet_blob_presenter.rb6
-rw-r--r--app/serializers/group_child_entity.rb14
-rw-r--r--app/serializers/group_entity.rb4
-rw-r--r--app/serializers/issuable_sidebar_extras_entity.rb17
-rw-r--r--app/services/auth/container_registry_authentication_service.rb18
-rw-r--r--app/services/base_container_service.rb10
-rw-r--r--app/services/base_group_service.rb12
-rw-r--r--app/services/base_service.rb1
-rw-r--r--app/services/boards/issues/list_service.rb8
-rw-r--r--app/services/ci/after_requeue_job_service.rb13
-rw-r--r--app/services/ci/archive_trace_service.rb9
-rw-r--r--app/services/ci/drop_pipeline_service.rb8
-rw-r--r--app/services/ci/external_pull_requests/create_pipeline_service.rb10
-rw-r--r--app/services/ci/pipeline_schedules/calculate_next_run_service.rb1
-rw-r--r--app/services/ci/pipelines/add_job_service.rb4
-rw-r--r--app/services/ci/queue/build_queue_service.rb36
-rw-r--r--app/services/ci/queue/builds_table_strategy.rb8
-rw-r--r--app/services/ci/queue/pending_builds_strategy.rb22
-rw-r--r--app/services/ci/register_job_service.rb63
-rw-r--r--app/services/ci/stuck_builds/drop_helpers.rb58
-rw-r--r--app/services/ci/stuck_builds/drop_service.rb62
-rw-r--r--app/services/ci/update_build_queue_service.rb14
-rw-r--r--app/services/ci/update_pending_build_service.rb45
-rw-r--r--app/services/clusters/agents/refresh_authorization_service.rb106
-rw-r--r--app/services/concerns/members/bulk_create_users.rb76
-rw-r--r--app/services/customer_relations/organizations/base_service.rb17
-rw-r--r--app/services/customer_relations/organizations/create_service.rb30
-rw-r--r--app/services/customer_relations/organizations/update_service.rb24
-rw-r--r--app/services/dependency_proxy/image_ttl_group_policies/update_service.rb41
-rw-r--r--app/services/design_management/delete_designs_service.rb1
-rw-r--r--app/services/draft_notes/publish_service.rb20
-rw-r--r--app/services/emails/destroy_service.rb6
-rw-r--r--app/services/environments/auto_stop_service.rb12
-rw-r--r--app/services/environments/stop_service.rb16
-rw-r--r--app/services/error_tracking/collect_error_service.rb32
-rw-r--r--app/services/error_tracking/list_issues_service.rb4
-rw-r--r--app/services/feature_flags/base_service.rb9
-rw-r--r--app/services/feature_flags/create_service.rb4
-rw-r--r--app/services/feature_flags/update_service.rb38
-rw-r--r--app/services/git/base_hooks_service.rb10
-rw-r--r--app/services/git/branch_hooks_service.rb37
-rw-r--r--app/services/git/tag_hooks_service.rb6
-rw-r--r--app/services/groups/open_issues_count_service.rb27
-rw-r--r--app/services/groups/update_shared_runners_service.rb9
-rw-r--r--app/services/issuable/bulk_update_service.rb19
-rw-r--r--app/services/issuable_base_service.rb5
-rw-r--r--app/services/issue_rebalancing_service.rb136
-rw-r--r--app/services/issues/base_service.rb10
-rw-r--r--app/services/issues/build_service.rb13
-rw-r--r--app/services/issues/relative_position_rebalancing_service.rb193
-rw-r--r--app/services/issues/update_service.rb70
-rw-r--r--app/services/members/create_service.rb17
-rw-r--r--app/services/members/creator_service.rb64
-rw-r--r--app/services/members/groups/bulk_creator_service.rb9
-rw-r--r--app/services/members/invite_service.rb2
-rw-r--r--app/services/members/mailgun.rb8
-rw-r--r--app/services/members/mailgun/process_webhook_service.rb39
-rw-r--r--app/services/members/projects/bulk_creator_service.rb9
-rw-r--r--app/services/merge_requests/merge_to_ref_service.rb8
-rw-r--r--app/services/merge_requests/mergeability_check_service.rb4
-rw-r--r--app/services/merge_requests/squash_service.rb17
-rw-r--r--app/services/packages/composer/version_parser_service.rb11
-rw-r--r--app/services/packages/create_package_service.rb6
-rw-r--r--app/services/packages/generic/create_package_file_service.rb3
-rw-r--r--app/services/packages/maven/find_or_create_package_service.rb2
-rw-r--r--app/services/packages/nuget/update_package_from_metadata_service.rb33
-rw-r--r--app/services/pages/delete_service.rb3
-rw-r--r--app/services/pages/legacy_storage_lease.rb19
-rw-r--r--app/services/pages/migrate_legacy_storage_to_deployment_service.rb15
-rw-r--r--app/services/projects/batch_count_service.rb16
-rw-r--r--app/services/projects/batch_forks_count_service.rb23
-rw-r--r--app/services/projects/count_service.rb2
-rw-r--r--app/services/projects/create_service.rb19
-rw-r--r--app/services/projects/fork_service.rb3
-rw-r--r--app/services/projects/forks_count_service.rb2
-rw-r--r--app/services/projects/group_links/destroy_service.rb20
-rw-r--r--app/services/projects/move_deploy_keys_projects_service.rb2
-rw-r--r--app/services/projects/move_forks_service.rb2
-rw-r--r--app/services/projects/move_lfs_objects_projects_service.rb2
-rw-r--r--app/services/projects/move_notification_settings_service.rb2
-rw-r--r--app/services/projects/move_project_authorizations_service.rb2
-rw-r--r--app/services/projects/move_project_group_links_service.rb2
-rw-r--r--app/services/projects/move_project_members_service.rb2
-rw-r--r--app/services/projects/move_users_star_projects_service.rb2
-rw-r--r--app/services/projects/open_issues_count_service.rb98
-rw-r--r--app/services/projects/operations/update_service.rb1
-rw-r--r--app/services/projects/transfer_service.rb23
-rw-r--r--app/services/projects/update_pages_service.rb104
-rw-r--r--app/services/projects/update_service.rb11
-rw-r--r--app/services/protected_branches/api_service.rb2
-rw-r--r--app/services/protected_branches/base_service.rb17
-rw-r--r--app/services/protected_branches/create_service.rb2
-rw-r--r--app/services/protected_branches/destroy_service.rb2
-rw-r--r--app/services/protected_branches/update_service.rb10
-rw-r--r--app/services/repositories/changelog_service.rb2
-rw-r--r--app/services/search/global_service.rb2
-rw-r--r--app/services/service_ping/submit_service.rb17
-rw-r--r--app/services/suggestions/apply_service.rb2
-rw-r--r--app/services/suggestions/create_service.rb2
-rw-r--r--app/services/system_note_service.rb4
-rw-r--r--app/services/system_notes/issuables_service.rb6
-rw-r--r--app/services/todos/destroy/design_service.rb28
-rw-r--r--app/services/users/ban_service.rb4
-rw-r--r--app/services/users/banned_user_base_service.rb5
-rw-r--r--app/services/users/dismiss_group_callout_service.rb11
-rw-r--r--app/services/users/dismiss_user_callout_service.rb10
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb29
-rw-r--r--app/services/users/reject_service.rb6
-rw-r--r--app/services/users/unban_service.rb6
-rw-r--r--app/services/wiki_pages/event_create_service.rb6
-rw-r--r--app/validators/gitlab/utils/zoom_url_validator.rb22
-rw-r--r--app/validators/gitlab/zoom_url_validator.rb22
-rw-r--r--app/validators/json_schemas/cluster_agent_authorization_configuration.json6
-rw-r--r--app/validators/json_schemas/dast_profile_schedule_cadence.json31
-rw-r--r--app/validators/json_schemas/error_tracking_event_payload.json148
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml2
-rw-r--r--app/views/admin/application_settings/_eks.html.haml10
-rw-r--r--app/views/admin/application_settings/_email.html.haml8
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml2
-rw-r--r--app/views/admin/application_settings/_files_limits.html.haml34
-rw-r--r--app/views/admin/application_settings/_floc.html.haml2
-rw-r--r--app/views/admin/application_settings/_git_lfs_limits.html.haml21
-rw-r--r--app/views/admin/application_settings/_gitpod.html.haml2
-rw-r--r--app/views/admin/application_settings/_import_export_limits.html.haml18
-rw-r--r--app/views/admin/application_settings/_initial_branch_name.html.haml2
-rw-r--r--app/views/admin/application_settings/_ip_limits.html.haml85
-rw-r--r--app/views/admin/application_settings/_issue_limits.html.haml2
-rw-r--r--app/views/admin/application_settings/_mailgun.html.haml2
-rw-r--r--app/views/admin/application_settings/_note_limits.html.haml7
-rw-r--r--app/views/admin/application_settings/_outbound.html.haml2
-rw-r--r--app/views/admin/application_settings/_package_registry_limits.html.haml21
-rw-r--r--app/views/admin/application_settings/_protected_paths.html.haml2
-rw-r--r--app/views/admin/application_settings/_sidekiq_job_limits.html.haml21
-rw-r--r--app/views/admin/application_settings/network.html.haml43
-rw-r--r--app/views/admin/application_settings/preferences.html.haml14
-rw-r--r--app/views/admin/applications/_form.html.haml8
-rw-r--r--app/views/admin/applications/edit.html.haml3
-rw-r--r--app/views/admin/background_migrations/_migration.html.haml4
-rw-r--r--app/views/admin/deploy_keys/new.html.haml2
-rw-r--r--app/views/admin/identities/edit.html.haml4
-rw-r--r--app/views/admin/identities/index.html.haml2
-rw-r--r--app/views/admin/identities/new.html.haml6
-rw-r--r--app/views/admin/impersonation_tokens/index.html.haml2
-rw-r--r--app/views/admin/projects/index.html.haml21
-rw-r--r--app/views/admin/runners/show.html.haml14
-rw-r--r--app/views/ci/group_variables/_variable_header.html.haml6
-rw-r--r--app/views/ci/variables/_index.html.haml2
-rw-r--r--app/views/devise/mailer/unlock_instructions.html.haml4
-rw-r--r--app/views/devise/mailer/unlock_instructions.text.erb4
-rw-r--r--app/views/devise/shared/_footer.html.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml4
-rw-r--r--app/views/devise/shared/_signup_box.html.haml1
-rw-r--r--app/views/devise/shared/_tabs_normal.html.haml2
-rw-r--r--app/views/errors/access_denied.html.haml2
-rw-r--r--app/views/groups/_group_admin_settings.html.haml4
-rw-r--r--app/views/groups/_home_panel.html.haml6
-rw-r--r--app/views/groups/_new_group_fields.html.haml5
-rw-r--r--app/views/groups/_personalize.html.haml27
-rw-r--r--app/views/groups/dependency_proxies/_url.html.haml2
-rw-r--r--app/views/groups/issues.html.haml47
-rw-r--r--app/views/groups/new.html.haml2
-rw-r--r--app/views/groups/runners/_runner.html.haml3
-rw-r--r--app/views/groups/runners/index.html.haml2
-rw-r--r--app/views/groups/settings/_membership.html.haml6
-rw-r--r--app/views/groups/settings/_permissions.html.haml5
-rw-r--r--app/views/groups/settings/_two_factor_auth.html.haml2
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml6
-rw-r--r--app/views/groups/settings/repository/_initial_branch_name.html.haml2
-rw-r--r--app/views/groups/show.html.haml6
-rw-r--r--app/views/help/instance_configuration.html.haml2
-rw-r--r--app/views/help/instance_configuration/_gitlab_ci.html.haml24
-rw-r--r--app/views/help/instance_configuration/_gitlab_pages.html.haml5
-rw-r--r--app/views/help/instance_configuration/_size_limits.html.haml40
-rw-r--r--app/views/issues/_issue.atom.builder39
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/layouts/_search.html.haml2
-rw-r--r--app/views/layouts/_snowplow.html.haml3
-rw-r--r--app/views/layouts/header/_default.html.haml20
-rw-r--r--app/views/layouts/header/_new_dropdown.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_group_menus.html.haml3
-rw-r--r--app/views/notify/member_invited_email.html.haml5
-rw-r--r--app/views/profiles/_email_settings.html.haml2
-rw-r--r--app/views/profiles/emails/index.html.haml8
-rw-r--r--app/views/profiles/keys/show.html.haml2
-rw-r--r--app/views/profiles/notifications/_email_settings.html.haml2
-rw-r--r--app/views/profiles/show.html.haml5
-rw-r--r--app/views/projects/_commit_button.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml6
-rw-r--r--app/views/projects/_import_project_pane.html.haml2
-rw-r--r--app/views/projects/_invite_members_empty_project.html.haml2
-rw-r--r--app/views/projects/_new_project_fields.html.haml34
-rw-r--r--app/views/projects/blob/_new_dir.html.haml2
-rw-r--r--app/views/projects/blob/_remove.html.haml2
-rw-r--r--app/views/projects/blob/show.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_notebook.html.haml2
-rw-r--r--app/views/projects/branches/new.html.haml4
-rw-r--r--app/views/projects/ci/builds/_build.html.haml6
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml4
-rw-r--r--app/views/projects/diffs/_stats.html.haml42
-rw-r--r--app/views/projects/error_tracking/details.html.haml2
-rw-r--r--app/views/projects/hooks/edit.html.haml2
-rw-r--r--app/views/projects/imports/new.html.haml2
-rw-r--r--app/views/projects/issues/_issue.html.haml4
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml2
-rw-r--r--app/views/projects/issues/index.html.haml4
-rw-r--r--app/views/projects/jobs/index.html.haml3
-rw-r--r--app/views/projects/labels/edit.html.haml2
-rw-r--r--app/views/projects/labels/new.html.haml2
-rw-r--r--app/views/projects/mattermosts/_team_selection.html.haml2
-rw-r--r--app/views/projects/merge_requests/_merge_request.atom.builder8
-rw-r--r--app/views/projects/merge_requests/_merge_requests.html.haml2
-rw-r--r--app/views/projects/merge_requests/_nav_btns.html.haml3
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_compare.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/_new_submit.html.haml2
-rw-r--r--app/views/projects/merge_requests/index.atom.builder11
-rw-r--r--app/views/projects/merge_requests/index.html.haml5
-rw-r--r--app/views/projects/merge_requests/show.html.haml7
-rw-r--r--app/views/projects/packages/packages/show.html.haml5
-rw-r--r--app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml2
-rw-r--r--app/views/projects/pipeline_schedules/new.html.haml2
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/projects/pipelines/show.html.haml2
-rw-r--r--app/views/projects/project_members/index.html.haml7
-rw-r--r--app/views/projects/project_templates/_template.html.haml4
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml23
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml2
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml1
-rw-r--r--app/views/projects/tags/releases/edit.html.haml4
-rw-r--r--app/views/projects/usage_quotas/index.html.haml19
-rw-r--r--app/views/projects/work_items/index.html.haml3
-rw-r--r--app/views/registrations/experience_levels/show.html.haml29
-rw-r--r--app/views/search/_category.html.haml8
-rw-r--r--app/views/search/results/_blob_data.html.haml4
-rw-r--r--app/views/search/results/_commit.html.haml2
-rw-r--r--app/views/search/results/_issuable.html.haml2
-rw-r--r--app/views/search/results/_milestone.html.haml2
-rw-r--r--app/views/search/results/_note.html.haml2
-rw-r--r--app/views/search/results/_wiki_blob.html.haml2
-rw-r--r--app/views/shared/_help_dropdown_forum_link.html.haml2
-rw-r--r--app/views/shared/_two_factor_auth_recovery_settings_check.html.haml (renamed from app/views/shared/_check_recovery_settings.html.haml)2
-rw-r--r--app/views/shared/_visibility_level.html.haml2
-rw-r--r--app/views/shared/_visibility_radios.html.haml2
-rw-r--r--app/views/shared/access_tokens/_table.html.haml2
-rw-r--r--app/views/shared/boards/_show.html.haml8
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml27
-rw-r--r--app/views/shared/boards/components/sidebar/_assignee.html.haml10
-rw-r--r--app/views/shared/boards/components/sidebar/_due_date.html.haml31
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml34
-rw-r--r--app/views/shared/boards/components/sidebar/_milestone.html.haml29
-rw-r--r--app/views/shared/boards/components/sidebar/_notifications.html.haml5
-rw-r--r--app/views/shared/boards/components/sidebar/_time_tracker.html.haml6
-rw-r--r--app/views/shared/deploy_tokens/_form.html.haml2
-rw-r--r--app/views/shared/deploy_tokens/_table.html.haml2
-rw-r--r--app/views/shared/doorkeeper/applications/_form.html.haml6
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml4
-rw-r--r--app/views/shared/issuable/_board_create_list_dropdown.html.haml8
-rw-r--r--app/views/shared/issuable/_feed_buttons.html.haml10
-rw-r--r--app/views/shared/issuable/_form.html.haml2
-rw-r--r--app/views/shared/issuable/_issuable.atom.builder38
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml5
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar_user_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml1
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml66
-rw-r--r--app/views/shared/issuable/form/_type_selector.html.haml2
-rw-r--r--app/views/shared/issue_type/_details_header.html.haml2
-rw-r--r--app/views/shared/labels/_form.html.haml4
-rw-r--r--app/views/shared/members/_access_request_links.html.haml8
-rw-r--r--app/views/shared/runners/_runner_details.html.haml2
-rw-r--r--app/views/shared/wikis/pages.html.haml2
-rw-r--r--app/views/sherlock/queries/_backtrace.html.haml2
-rw-r--r--app/views/sherlock/queries/_general.html.haml2
-rw-r--r--app/views/users/_middle_dot_divider.html.haml5
-rw-r--r--app/views/users/_profile_basic_info.html.haml6
-rw-r--r--app/views/users/show.html.haml53
-rw-r--r--app/views/users/terms/index.html.haml4
-rw-r--r--app/workers/all_queues.yml307
-rw-r--r--app/workers/analytics/usage_trends/count_job_trigger_worker.rb1
-rw-r--r--app/workers/analytics/usage_trends/counter_job_worker.rb1
-rw-r--r--app/workers/approve_blocked_pending_approval_users_worker.rb1
-rw-r--r--app/workers/authorized_project_update/user_refresh_from_replica_worker.rb15
-rw-r--r--app/workers/background_migration_worker.rb23
-rw-r--r--app/workers/build_success_worker.rb2
-rw-r--r--app/workers/bulk_import_worker.rb14
-rw-r--r--app/workers/bulk_imports/entity_worker.rb1
-rw-r--r--app/workers/bulk_imports/pipeline_worker.rb1
-rw-r--r--app/workers/bulk_imports/relation_export_worker.rb1
-rw-r--r--app/workers/ci/delete_objects_worker.rb1
-rw-r--r--app/workers/ci/drop_pipeline_worker.rb2
-rw-r--r--app/workers/ci/external_pull_requests/create_pipeline_worker.rb40
-rw-r--r--app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb1
-rw-r--r--app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb1
-rw-r--r--app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb1
-rw-r--r--app/workers/ci/schedule_delete_objects_cron_worker.rb1
-rw-r--r--app/workers/ci/test_failure_history_worker.rb2
-rw-r--r--app/workers/concerns/application_worker.rb10
-rw-r--r--app/workers/concerns/chaos_queue.rb1
-rw-r--r--app/workers/concerns/worker_attributes.rb13
-rw-r--r--app/workers/container_expiration_policies/cleanup_container_repository_worker.rb1
-rw-r--r--app/workers/database/batched_background_migration_worker.rb1
-rw-r--r--app/workers/database/partition_management_worker.rb2
-rw-r--r--app/workers/deployments/drop_older_deployments_worker.rb1
-rw-r--r--app/workers/deployments/finished_worker.rb24
-rw-r--r--app/workers/deployments/forward_deployment_worker.rb20
-rw-r--r--app/workers/deployments/hooks_worker.rb2
-rw-r--r--app/workers/deployments/success_worker.rb25
-rw-r--r--app/workers/design_management/copy_design_collection_worker.rb1
-rw-r--r--app/workers/destroy_pages_deployments_worker.rb1
-rw-r--r--app/workers/disallow_two_factor_for_group_worker.rb1
-rw-r--r--app/workers/disallow_two_factor_for_subgroups_worker.rb1
-rw-r--r--app/workers/email_receiver_worker.rb2
-rw-r--r--app/workers/environments/auto_stop_worker.rb18
-rw-r--r--app/workers/environments/canary_ingress/update_worker.rb1
-rw-r--r--app/workers/experiments/record_conversion_event_worker.rb1
-rw-r--r--app/workers/expire_job_cache_worker.rb12
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb2
-rw-r--r--app/workers/flush_counter_increments_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_merged_by_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/import_pull_request_review_worker.rb1
-rw-r--r--app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb28
-rw-r--r--app/workers/gitlab/github_import/stage/import_notes_worker.rb24
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb2
-rw-r--r--app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb2
-rw-r--r--app/workers/gitlab_performance_bar_stats_worker.rb1
-rw-r--r--app/workers/group_destroy_worker.rb1
-rw-r--r--app/workers/hashed_storage/migrator_worker.rb5
-rw-r--r--app/workers/hashed_storage/project_migrate_worker.rb5
-rw-r--r--app/workers/hashed_storage/project_rollback_worker.rb5
-rw-r--r--app/workers/hashed_storage/rollbacker_worker.rb5
-rw-r--r--app/workers/incident_management/add_severity_system_note_worker.rb1
-rw-r--r--app/workers/issue_rebalancing_worker.rb9
-rw-r--r--app/workers/jira_connect/sync_builds_worker.rb1
-rw-r--r--app/workers/jira_connect/sync_deployments_worker.rb1
-rw-r--r--app/workers/jira_connect/sync_feature_flags_worker.rb1
-rw-r--r--app/workers/jira_connect/sync_project_worker.rb1
-rw-r--r--app/workers/member_invitation_reminder_emails_worker.rb1
-rw-r--r--app/workers/merge_request_cleanup_refs_worker.rb1
-rw-r--r--app/workers/metrics/dashboard/sync_dashboards_worker.rb1
-rw-r--r--app/workers/namespaces/in_product_marketing_emails_worker.rb1
-rw-r--r--app/workers/namespaces/onboarding_issue_created_worker.rb1
-rw-r--r--app/workers/namespaces/onboarding_pipeline_created_worker.rb1
-rw-r--r--app/workers/namespaces/onboarding_progress_worker.rb1
-rw-r--r--app/workers/namespaces/onboarding_user_added_worker.rb1
-rw-r--r--app/workers/packages/composer/cache_cleanup_worker.rb1
-rw-r--r--app/workers/packages/composer/cache_update_worker.rb1
-rw-r--r--app/workers/packages/debian/process_changes_worker.rb8
-rw-r--r--app/workers/packages/go/sync_packages_worker.rb1
-rw-r--r--app/workers/packages/helm/extraction_worker.rb4
-rw-r--r--app/workers/packages/maven/metadata/sync_worker.rb1
-rw-r--r--app/workers/packages/rubygems/extraction_worker.rb1
-rw-r--r--app/workers/pages_domain_ssl_renewal_worker.rb1
-rw-r--r--app/workers/pages_domain_verification_worker.rb1
-rw-r--r--app/workers/pages_remove_worker.rb8
-rw-r--r--app/workers/pages_transfer_worker.rb1
-rw-r--r--app/workers/pages_update_configuration_worker.rb1
-rw-r--r--app/workers/pages_worker.rb1
-rw-r--r--app/workers/personal_access_tokens/expired_notification_worker.rb1
-rw-r--r--app/workers/post_receive.rb4
-rw-r--r--app/workers/project_destroy_worker.rb1
-rw-r--r--app/workers/projects/git_garbage_collect_worker.rb2
-rw-r--r--app/workers/projects/post_creation_worker.rb1
-rw-r--r--app/workers/propagate_integration_group_worker.rb1
-rw-r--r--app/workers/propagate_integration_inherit_descendant_worker.rb1
-rw-r--r--app/workers/propagate_integration_inherit_worker.rb1
-rw-r--r--app/workers/propagate_integration_project_worker.rb1
-rw-r--r--app/workers/purge_dependency_proxy_cache_worker.rb2
-rw-r--r--app/workers/releases/create_evidence_worker.rb1
-rw-r--r--app/workers/releases/manage_evidence_worker.rb1
-rw-r--r--app/workers/remove_unaccepted_member_invites_worker.rb1
-rw-r--r--app/workers/schedule_merge_request_cleanup_refs_worker.rb1
-rw-r--r--app/workers/service_desk_email_receiver_worker.rb3
-rw-r--r--app/workers/ssh_keys/expired_notification_worker.rb1
-rw-r--r--app/workers/ssh_keys/expiring_soon_notification_worker.rb1
-rw-r--r--app/workers/stuck_ci_jobs_worker.rb95
-rw-r--r--app/workers/todos_destroyer/destroyed_designs_worker.rb18
-rw-r--r--app/workers/todos_destroyer/destroyed_issuable_worker.rb2
-rw-r--r--app/workers/user_status_cleanup/batch_worker.rb1
-rw-r--r--app/workers/users/deactivate_dormant_users_worker.rb1
-rw-r--r--app/workers/web_hooks/destroy_worker.rb1
-rw-r--r--app/workers/web_hooks/log_execution_worker.rb2
-rw-r--r--app/workers/wikis/git_garbage_collect_worker.rb2
1329 files changed, 14709 insertions, 12393 deletions
diff --git a/app/assets/images/learn_gitlab/code_owners_enabled.svg b/app/assets/images/learn_gitlab/code_owners_enabled.svg
deleted file mode 100644
index 019d74c64cc..00000000000
--- a/app/assets/images/learn_gitlab/code_owners_enabled.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M13 25C19.6274 25 25 19.6274 25 13C25 6.37258 19.6274 1 13 1C6.37258 1 1 6.37258 1 13C1 19.6274 6.37258 25 13 25Z" fill="white" stroke="#C2B7E6" stroke-width="2"/>
-<path d="M1.16748 12.3359C2.88075 11.7701 4.4618 10.8635 5.81545 9.67055C7.16911 8.47763 8.26738 7.02313 9.04415 5.39461M6.94481 2.60461C9.28681 6.43995 13.5115 8.99995 18.3335 8.99995C20.2715 8.99995 22.1135 8.58661 23.7748 7.84261L6.94481 2.60461Z" stroke="#C2B7E6"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1176 15.8941H15.7647C15.7647 17.447 14.4941 18.7176 12.9412 18.7176C11.3882 18.7176 10.1176 17.447 10.1176 15.8941ZM9.05882 15.1882C8.47294 15.1882 8 14.7153 8 14.1294C8 13.5435 8.47294 13.0706 9.05882 13.0706C9.64471 13.0706 10.1176 13.5435 10.1176 14.1294C10.1176 14.7153 9.64471 15.1882 9.05882 15.1882ZM16.8235 15.1882C16.2376 15.1882 15.7647 14.7153 15.7647 14.1294C15.7647 13.5435 16.2376 13.0706 16.8235 13.0706C17.4094 13.0706 17.8824 13.5435 17.8824 14.1294C17.8824 14.7153 17.4094 15.1882 16.8235 15.1882Z" fill="#6B4FBB"/>
-</svg>
diff --git a/app/assets/images/learn_gitlab/git_write.svg b/app/assets/images/learn_gitlab/git_write.svg
deleted file mode 100644
index ad87b3f3b12..00000000000
--- a/app/assets/images/learn_gitlab/git_write.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-<svg width="40" height="39" viewBox="0 0 40 39" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0)">
-<path d="M32.2886 3.99573H37.8624C38.1834 3.99573 38.4435 4.25587 38.4435 4.57679V33.9598C38.4435 34.2808 38.1834 34.5409 37.8624 34.5409H32.2886V3.99573Z" fill="#F0F0F0" stroke="#DBDBDB" stroke-width="2"/>
-<path d="M10.757 9.4011L10.7363 4.92686C10.7337 4.35386 11.1491 3.86447 11.7148 3.77395L30.908 0.703095C31.614 0.590124 32.2537 1.13556 32.2537 1.85062V37.0106C32.2537 37.723 31.6184 38.2678 30.9143 38.1591L11.8555 35.2171C11.2908 35.13 10.8733 34.6453 10.8707 34.074L10.8502 29.6368" stroke="#DBDBDB" stroke-width="2"/>
-<path d="M11.2195 29.7561C16.877 29.7561 21.4634 25.1698 21.4634 19.5122C21.4634 13.8547 16.877 9.26831 11.2195 9.26831C5.56194 9.26831 0.975586 13.8547 0.975586 19.5122C0.975586 25.1698 5.56194 29.7561 11.2195 29.7561Z" stroke="#6E49CB"/>
-<path d="M11.2194 27.8048C15.7994 27.8048 19.5121 24.0921 19.5121 19.5122C19.5121 14.9322 15.7994 11.2195 11.2194 11.2195C6.63952 11.2195 2.92676 14.9322 2.92676 19.5122C2.92676 24.0921 6.63952 27.8048 11.2194 27.8048Z" fill="#6E49CB"/>
-<path d="M11.2194 27.8048C15.7994 27.8048 19.5121 24.0921 19.5121 19.5122C19.5121 14.9322 15.7994 11.2195 11.2194 11.2195C6.63952 11.2195 2.92676 14.9322 2.92676 19.5122C2.92676 24.0921 6.63952 27.8048 11.2194 27.8048Z" fill="white" fill-opacity="0.9"/>
-<path d="M10.8843 23.4146V16.276" stroke="#6E49CB" stroke-linecap="round"/>
-<path d="M7.31689 19.6609H14.634" stroke="#6E49CB" stroke-linecap="round"/>
-</g>
-<defs>
-<clipPath id="clip0">
-<rect width="40" height="39.0244" fill="white"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/app/assets/images/learn_gitlab/issue_created.svg b/app/assets/images/learn_gitlab/issue_created.svg
deleted file mode 100644
index 01652b97fc0..00000000000
--- a/app/assets/images/learn_gitlab/issue_created.svg
+++ /dev/null
@@ -1,65 +0,0 @@
-<svg width="81" height="48" viewBox="0 0 81 48" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0)">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M42.9799 11.6386C42.9688 11.7501 42.955 11.8786 42.9384 12.0222C42.8865 12.4687 42.8257 12.9142 42.756 13.3582C42.7493 13.3954 42.7501 13.4335 42.7584 13.4704C42.7667 13.5072 42.7822 13.542 42.8041 13.5728C42.826 13.6035 42.8538 13.6296 42.8859 13.6495C42.918 13.6693 42.9538 13.6826 42.9911 13.6884C43.0284 13.6943 43.0664 13.6926 43.1031 13.6835C43.1397 13.6745 43.1742 13.6582 43.2045 13.6356C43.2347 13.613 43.2602 13.5847 43.2793 13.5521C43.2985 13.5196 43.3109 13.4835 43.316 13.4461C43.3915 12.9637 43.4531 12.506 43.5015 12.0874C43.5221 11.9092 43.5408 11.7308 43.5577 11.5522C43.5609 11.5151 43.5568 11.4777 43.5456 11.4422C43.5343 11.4067 43.5162 11.3738 43.4923 11.3453C43.4684 11.3168 43.439 11.2933 43.406 11.2761C43.373 11.2589 43.3369 11.2484 43.2998 11.2452C43.2627 11.242 43.2253 11.2461 43.1898 11.2573C43.1543 11.2685 43.1214 11.2866 43.0929 11.3106C43.0644 11.3345 43.0409 11.3638 43.0237 11.3969C43.0065 11.4299 42.996 11.466 42.9928 11.5031C42.9909 11.5263 42.9866 11.572 42.9799 11.6386ZM41.9287 16.9968C41.9171 17.0322 41.9127 17.0695 41.9156 17.1066C41.9185 17.1438 41.9287 17.1799 41.9456 17.2131C41.9624 17.2463 41.9857 17.2758 42.014 17.3C42.0423 17.3242 42.0751 17.3426 42.1105 17.3542C42.1459 17.3657 42.1832 17.3701 42.2203 17.3672C42.2574 17.3643 42.2936 17.3541 42.3268 17.3373C42.36 17.3204 42.3895 17.2971 42.4137 17.2688C42.4379 17.2405 42.4563 17.2078 42.4678 17.1724C42.6521 16.606 42.8172 15.985 42.9645 15.321C42.9734 15.2843 42.975 15.2462 42.9691 15.2089C42.9631 15.1716 42.9498 15.1359 42.9299 15.1039C42.91 15.0718 42.8839 15.044 42.8531 15.0222C42.8223 15.0004 42.7874 14.9849 42.7506 14.9767C42.7137 14.9686 42.6756 14.9678 42.6385 14.9746C42.6013 14.9813 42.5659 14.9954 42.5343 15.016C42.5027 15.0367 42.4755 15.0634 42.4543 15.0947C42.4332 15.1259 42.4185 15.1611 42.4111 15.1981C42.2675 15.8456 42.1069 16.4491 41.9287 16.9968ZM40.0489 19.874C40.0136 19.8862 39.981 19.9053 39.9531 19.9302C39.9252 19.955 39.9025 19.9852 39.8863 20.0189C39.8701 20.0526 39.8607 20.0891 39.8587 20.1265C39.8566 20.1638 39.862 20.2012 39.8745 20.2364C39.887 20.2716 39.9063 20.3041 39.9313 20.3318C39.9564 20.3596 39.9867 20.3821 40.0205 20.3981C40.0543 20.4141 40.0909 20.4232 40.1283 20.425C40.1656 20.4267 40.2029 20.4211 40.2381 20.4084C40.8012 20.209 41.2854 19.713 41.7083 18.9671C41.7454 18.9017 41.755 18.8242 41.735 18.7517C41.715 18.6792 41.667 18.6177 41.6016 18.5806C41.5362 18.5435 41.4587 18.5339 41.3862 18.554C41.3137 18.574 41.2522 18.622 41.2151 18.6874C40.8532 19.3257 40.4601 19.7283 40.0489 19.874ZM36.3662 20.7087C36.3319 20.7231 36.3007 20.7442 36.2746 20.7706C36.2484 20.7971 36.2277 20.8285 36.2136 20.863C36.1996 20.8974 36.1925 20.9343 36.1927 20.9716C36.1929 21.0088 36.2004 21.0456 36.2149 21.0799C36.2293 21.1142 36.2504 21.1454 36.2769 21.1715C36.3033 21.1977 36.3347 21.2184 36.3692 21.2324C36.4037 21.2465 36.4406 21.2536 36.4778 21.2534C36.515 21.2532 36.5518 21.2456 36.5861 21.2312C37.1757 20.9829 37.7714 20.792 38.3357 20.6679C38.4091 20.6517 38.4731 20.607 38.5136 20.5437C38.5541 20.4803 38.5677 20.4035 38.5516 20.3301C38.5354 20.2566 38.4907 20.1926 38.4274 20.1521C38.364 20.1117 38.2872 20.098 38.2138 20.1142C37.6153 20.2459 36.9871 20.4473 36.3662 20.7087ZM33.1143 22.7955C33.0607 22.8475 33.0298 22.9186 33.0283 22.9932C33.0267 23.0678 33.0547 23.1401 33.1061 23.1942C33.1576 23.2483 33.2282 23.28 33.3029 23.2823C33.3775 23.2846 33.45 23.2574 33.5047 23.2066C33.9359 22.7967 34.4242 22.42 34.9553 22.0829C35.0187 22.0426 35.0636 21.9788 35.08 21.9054C35.0964 21.832 35.083 21.7551 35.0427 21.6916C35.0024 21.6282 34.9385 21.5833 34.8651 21.5669C34.7918 21.5505 34.7149 21.5639 34.6514 21.6042C34.0901 21.9605 33.5729 22.3598 33.1143 22.7955ZM31.0777 26.1259C31.0686 26.162 31.0667 26.1995 31.0721 26.2364C31.0776 26.2732 31.0902 26.3086 31.1093 26.3406C31.1284 26.3725 31.1536 26.4004 31.1835 26.4226C31.2134 26.4448 31.2474 26.4609 31.2835 26.47C31.3196 26.4791 31.3571 26.481 31.3939 26.4755C31.4308 26.4701 31.4662 26.4575 31.4981 26.4384C31.5301 26.4193 31.558 26.394 31.5802 26.3642C31.6024 26.3343 31.6185 26.3003 31.6276 26.2642C31.7727 25.6871 32.0133 25.1347 32.3419 24.611C32.3799 24.5474 32.3915 24.4715 32.3742 24.3994C32.3569 24.3274 32.3121 24.265 32.2493 24.2256C32.1866 24.1862 32.1109 24.1729 32.0385 24.1886C31.9661 24.2043 31.9027 24.2478 31.8619 24.3096C31.5023 24.8826 31.2379 25.4896 31.0777 26.1259ZM31.0276 29.9893C31.0322 30.0262 31.0441 30.0619 31.0626 30.0943C31.081 30.1266 31.1056 30.155 31.135 30.1778C31.1644 30.2007 31.1981 30.2175 31.234 30.2273C31.2699 30.2371 31.3074 30.2398 31.3443 30.2352C31.3813 30.2305 31.4169 30.2186 31.4493 30.2002C31.4816 30.1818 31.51 30.1571 31.5328 30.1277C31.5557 30.0983 31.5725 30.0647 31.5823 30.0288C31.5922 29.9929 31.5948 29.9554 31.5902 29.9184C31.5208 29.3685 31.4806 28.7587 31.4683 28.0633C31.467 27.9881 31.4358 27.9165 31.3817 27.8643C31.3276 27.8121 31.255 27.7835 31.1798 27.7848C31.1046 27.7861 31.0331 27.8173 30.9808 27.8714C30.9286 27.9255 30.9 27.9981 30.9014 28.0733C30.914 28.7882 30.9556 29.418 31.0276 29.9893ZM32.2028 33.6691C32.238 33.7355 32.2981 33.7853 32.37 33.8074C32.4418 33.8295 32.5195 33.8222 32.586 33.7871C32.6524 33.7519 32.7022 33.6918 32.7243 33.6199C32.7465 33.5481 32.7392 33.4704 32.704 33.4039C32.3876 32.8064 32.1554 32.271 31.9768 31.7139C31.9539 31.6423 31.9034 31.5828 31.8365 31.5484C31.7697 31.514 31.6919 31.5075 31.6203 31.5305C31.5487 31.5535 31.4892 31.6039 31.4548 31.6708C31.4204 31.7376 31.414 31.8154 31.4369 31.887C31.6265 32.4779 31.8719 33.0435 32.2028 33.6691ZM33.6326 36.0823C33.8058 36.3634 33.9778 36.6453 34.1485 36.9279C34.1874 36.9923 34.2502 37.0386 34.3233 37.0567C34.3963 37.0747 34.4734 37.063 34.5378 37.0241C34.6022 36.9853 34.6485 36.9224 34.6665 36.8494C34.6846 36.7764 34.6729 36.6992 34.634 36.6348C34.4623 36.351 34.2895 36.0678 34.1155 35.7854C34.1306 35.81 33.7462 35.1858 33.6457 35.0219C33.6264 34.9897 33.601 34.9617 33.5709 34.9394C33.5407 34.9171 33.5065 34.901 33.4701 34.892C33.4337 34.8831 33.3958 34.8814 33.3588 34.8872C33.3217 34.8929 33.2862 34.906 33.2542 34.9256C33.2222 34.9451 33.1945 34.9709 33.1725 35.0013C33.1506 35.0317 33.1349 35.0661 33.1263 35.1026C33.1177 35.1391 33.1165 35.177 33.1227 35.2139C33.1289 35.2509 33.1423 35.2863 33.1623 35.3181C33.2632 35.4825 33.6481 36.1076 33.6326 36.0825V36.0823ZM35.3062 40.3242C35.3067 40.3615 35.3145 40.3982 35.3292 40.4324C35.3439 40.4666 35.3652 40.4976 35.3918 40.5236C35.4185 40.5496 35.45 40.57 35.4846 40.5838C35.5192 40.5976 35.5561 40.6045 35.5933 40.604C35.6306 40.6035 35.6673 40.5957 35.7015 40.581C35.7357 40.5663 35.7667 40.545 35.7927 40.5184C35.8187 40.4917 35.8392 40.4602 35.853 40.4256C35.8668 40.391 35.8736 40.3541 35.8731 40.3169C35.8648 39.6518 35.7475 38.9926 35.526 38.3655C35.4998 38.2962 35.4474 38.2399 35.3802 38.2087C35.313 38.1775 35.2363 38.1738 35.1664 38.1985C35.0966 38.2232 35.0392 38.2743 35.0065 38.3407C34.9738 38.4072 34.9684 38.4839 34.9915 38.5543C35.1924 39.1231 35.2987 39.721 35.3062 40.3242ZM34.27 43.7311C34.2299 43.7937 34.216 43.8695 34.2313 43.9422C34.2466 44.015 34.2898 44.0788 34.3517 44.12C34.4135 44.1612 34.4891 44.1764 34.5621 44.1624C34.6351 44.1484 34.6997 44.1063 34.7419 44.0452C35.109 43.4961 35.3947 42.8967 35.59 42.2658C35.6121 42.1939 35.6048 42.1162 35.5696 42.0497C35.5344 41.9833 35.4743 41.9335 35.4024 41.9114C35.3305 41.8893 35.2528 41.8967 35.1864 41.9319C35.1199 41.9671 35.0702 42.0272 35.0481 42.0991C34.8689 42.6778 34.6068 43.2275 34.27 43.7311Z" fill="#FDE5D8"/>
-<path d="M60.8446 31.6803L53.1713 38.2926M49.7697 32.4009L59.3328 29.0618L49.7697 32.4009Z" stroke="#FDE5D8" stroke-linecap="round"/>
-<path d="M60.2631 30.4887C60.3987 30.4104 60.4451 30.237 60.3668 30.1014C60.2886 29.9659 60.1152 29.9194 59.9796 29.9977L54.1288 33.3756C53.9932 33.4539 53.9468 33.6273 54.0251 33.7629C54.1033 33.8984 54.2767 33.9449 54.4123 33.8666L60.2631 30.4887Z" fill="#FDE5D8"/>
-<path d="M63.2421 30.9507L61.2578 27.5138C61.1535 27.3331 60.9223 27.2711 60.7415 27.3755L59.9327 27.8425C59.752 27.9468 59.69 28.178 59.7944 28.3588L61.7786 31.7956C61.883 31.9764 62.1142 32.0383 62.2949 31.9339L63.1037 31.467C63.2845 31.3626 63.3464 31.1314 63.2421 30.9507Z" fill="white" stroke="#FDE5D8"/>
-<path d="M69.8124 20.1746L73.8754 27.2119L63.7936 33.0326L59.7306 25.9953L69.8124 20.1746Z" stroke="#FDE5D8"/>
-<path d="M68.6454 29.795L68.3599 32.8374C68.35 32.9411 68.2766 33.0767 68.1935 33.1419L64.4623 36.0762C64.2994 36.2045 64.2327 36.1536 64.3133 35.9633L65.268 33.7089L65.5559 31.5788" stroke="#FDE5D8"/>
-<path d="M64.9604 23.4123L62.1829 22.1385C62.088 22.0951 61.934 22.0911 61.836 22.1302L57.4292 23.8944C57.2367 23.9715 57.2473 24.0546 57.4525 24.08L59.8823 24.3805L61.8709 25.1962" stroke="#FDE5D8"/>
-<path d="M69.8126 20.1746L76.321 20.7344C76.6326 20.7612 76.778 21.0118 76.645 21.2955L73.8758 27.2118" stroke="#FDE5D8"/>
-<path d="M62.0503 27.3839C62.1285 27.5195 62.3012 27.5663 62.4359 27.4886C62.5705 27.4108 62.6163 27.2379 62.538 27.1023C62.4597 26.9667 62.2871 26.9198 62.1524 26.9976C62.0177 27.0754 61.972 27.2483 62.0503 27.3839Z" fill="#FC8A51"/>
-<path d="M62.6173 28.3658C62.6955 28.5014 62.8682 28.5483 63.0028 28.4705C63.1375 28.3928 63.1832 28.2198 63.105 28.0842C63.0267 27.9487 62.8541 27.9018 62.7194 27.9795C62.5847 28.0573 62.539 28.2302 62.6173 28.3658Z" fill="#FC8A51"/>
-<path d="M63.1841 29.3476C63.2624 29.4832 63.435 29.5301 63.5697 29.4523C63.7044 29.3746 63.7501 29.2016 63.6718 29.066C63.5935 28.9305 63.4209 28.8836 63.2862 28.9613C63.1516 29.0391 63.1058 29.212 63.1841 29.3476Z" fill="#FC8A51"/>
-<path d="M63.7511 30.3297C63.8294 30.4653 64.002 30.5121 64.1367 30.4344C64.2714 30.3566 64.3171 30.1837 64.2388 30.0481C64.1605 29.9125 63.9879 29.8656 63.8532 29.9434C63.7186 30.0212 63.6728 30.1941 63.7511 30.3297Z" fill="#FC8A51"/>
-<path d="M65.9899 27.0731C66.5378 28.0221 67.7464 28.3501 68.6894 27.8057C69.6324 27.2612 69.9527 26.0505 69.4048 25.1015C68.8568 24.1524 67.6482 23.8244 66.7052 24.3689C65.7622 24.9133 65.4419 26.124 65.9899 27.0731Z" stroke="#FDE5D8"/>
-<path d="M66.8032 26.6036C67.0902 27.1008 67.7233 27.2726 68.2173 26.9874C68.7113 26.7022 68.879 26.068 68.592 25.5709C68.305 25.0738 67.6719 24.902 67.1779 25.1872C66.684 25.4723 66.5162 26.1065 66.8032 26.6036Z" stroke="#FDE5D8"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M18.5279 7.40988C18.4991 7.72887 18.5432 8.13101 18.7163 8.57152C18.7364 8.61656 18.7731 8.65211 18.8188 8.6708C18.8645 8.68948 18.9156 8.68987 18.9615 8.67188C19.0075 8.65389 19.0447 8.61891 19.0656 8.57418C19.0864 8.52945 19.0892 8.47842 19.0734 8.43167C18.924 8.05108 18.8863 7.70752 18.91 7.44408C18.9119 7.42254 18.9137 7.40893 18.9145 7.40421C18.9193 7.37911 18.9189 7.35331 18.9136 7.32834C18.9082 7.30336 18.8979 7.2797 18.8833 7.25876C18.8687 7.23781 18.85 7.22 18.8284 7.20637C18.8068 7.19273 18.7827 7.18355 18.7575 7.17936C18.7323 7.17517 18.7065 7.17605 18.6817 7.18196C18.6568 7.18787 18.6334 7.19868 18.6128 7.21376C18.5922 7.22885 18.5748 7.2479 18.5616 7.26979C18.5485 7.29169 18.5398 7.31599 18.5362 7.34128C18.5326 7.36404 18.5298 7.38692 18.5279 7.40988ZM20.6986 10.3736C20.7914 10.4189 20.9061 10.3855 20.9547 10.2991C21.0033 10.213 20.9675 10.1064 20.8746 10.061C20.5219 9.88963 20.2211 9.69933 19.9686 9.49278C19.9299 9.46178 19.8811 9.44626 19.8316 9.44923C19.7822 9.4522 19.7355 9.47344 19.7008 9.50884C19.6845 9.52574 19.6719 9.5459 19.6638 9.568C19.6557 9.59009 19.6524 9.61364 19.654 9.63711C19.6556 9.66058 19.6622 9.68344 19.6732 9.70422C19.6842 9.725 19.6995 9.74323 19.718 9.75772C19.9936 9.98298 20.3195 10.1892 20.6988 10.3738L20.6986 10.3736ZM23.1801 11.5188C23.2048 11.5264 23.2308 11.5289 23.2565 11.5263C23.2822 11.5238 23.3072 11.5161 23.3299 11.5038C23.3527 11.4915 23.3727 11.4748 23.389 11.4546C23.4052 11.4345 23.4172 11.4113 23.4244 11.3865C23.4399 11.3362 23.435 11.2819 23.4107 11.2352C23.3865 11.1886 23.3448 11.1533 23.2948 11.1371C22.9212 11.0216 22.5486 10.903 22.177 10.7814C22.1524 10.7735 22.1264 10.7705 22.1007 10.7727C22.0749 10.7749 22.0498 10.7822 22.0269 10.7941C22.004 10.8061 21.9837 10.8225 21.9672 10.8425C21.9507 10.8624 21.9383 10.8854 21.9308 10.9101C21.9146 10.9602 21.9187 11.0146 21.9423 11.0616C21.966 11.1087 22.0072 11.1445 22.057 11.1614C22.3837 11.269 22.606 11.3391 23.1801 11.5188ZM25.4353 12.2611C25.5204 12.3076 25.6285 12.2796 25.6772 12.1987C25.6887 12.1795 25.6962 12.158 25.6991 12.1358C25.7021 12.1135 25.7004 12.0909 25.6943 12.0692C25.6882 12.0476 25.6777 12.0275 25.6635 12.0101C25.6493 11.9927 25.6318 11.9784 25.6118 11.968C25.3295 11.8132 25.0086 11.676 24.6165 11.5381C24.5246 11.5059 24.4228 11.5507 24.389 11.6382C24.3549 11.7259 24.4022 11.823 24.4938 11.8554C24.8688 11.9871 25.1723 12.1169 25.4353 12.2611ZM27.2083 14.2457C27.247 14.3391 27.3614 14.3859 27.4632 14.3504C27.5653 14.3149 27.6163 14.2106 27.5776 14.1172C27.4169 13.7302 27.2274 13.3999 27.0036 13.1147C26.94 13.0336 26.8166 13.0151 26.7279 13.0735C26.6393 13.1317 26.6193 13.2447 26.6829 13.3258C26.8859 13.5843 27.0594 13.887 27.2083 14.2457ZM27.7057 16.8237C27.7091 16.9282 27.813 17.0108 27.9378 17.008C28.0625 17.005 28.1607 16.9178 28.1575 16.8133C28.1437 16.3968 28.1065 16.0185 28.04 15.6568C28.0209 15.5534 27.9054 15.4824 27.782 15.4984C27.6588 15.5143 27.5742 15.6113 27.5932 15.7146C27.6567 16.0595 27.6925 16.4224 27.7057 16.8237Z" fill="#EEEEEE"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M27.6172 18.4687C27.6047 18.7996 27.5957 19.1305 27.5903 19.4616C27.589 19.5661 27.6652 19.6521 27.7608 19.6536C27.8564 19.6551 27.9352 19.5718 27.9365 19.4675C27.9403 19.1902 27.9471 18.9608 27.9634 18.4827L27.9683 18.3378C27.9717 18.2334 27.897 18.1458 27.8016 18.142C27.706 18.1382 27.6257 18.2195 27.6221 18.324L27.6172 18.4687ZM27.7678 22.1483C27.7905 22.2513 27.895 22.3168 28.0014 22.2951C28.1076 22.2732 28.1754 22.1721 28.1529 22.0691C28.0741 21.71 28.0187 21.3434 27.9834 20.9609C27.9736 20.8562 27.878 20.7788 27.7697 20.7882C27.6614 20.7977 27.5815 20.8903 27.5911 20.995C27.6279 21.3922 27.6856 21.7737 27.7678 22.1483ZM28.7232 24.4772C28.7792 24.563 28.9033 24.5925 29.0005 24.543C29.0976 24.4937 29.1309 24.3841 29.0751 24.2983C28.8775 23.9966 28.7013 23.6814 28.5479 23.3551C28.505 23.2634 28.3863 23.22 28.2827 23.2578C28.1792 23.2956 28.1297 23.4004 28.1728 23.4919C28.333 23.8328 28.5169 24.1621 28.7232 24.4772ZM29.633 25.9325C29.8392 26.1713 30.063 26.3944 30.3025 26.5998C30.3398 26.6315 30.3877 26.6478 30.4366 26.6455C30.4855 26.6432 30.5317 26.6224 30.5658 26.5873C30.5821 26.5704 30.5948 26.5503 30.603 26.5282C30.6113 26.5062 30.6149 26.4827 30.6136 26.4592C30.6124 26.4357 30.6063 26.4127 30.5958 26.3916C30.5853 26.3706 30.5706 26.3519 30.5525 26.3368C30.3268 26.1431 30.1158 25.9329 29.9214 25.7078C29.8848 25.6652 29.8485 25.6222 29.8125 25.579C29.7807 25.5418 29.7359 25.5182 29.6872 25.5129C29.6386 25.5075 29.5897 25.5209 29.5506 25.5502C29.5315 25.5641 29.5156 25.5818 29.5036 25.6021C29.4917 25.6224 29.4841 25.645 29.4812 25.6684C29.4784 25.6918 29.4804 25.7155 29.4871 25.7381C29.4939 25.7607 29.5052 25.7816 29.5204 25.7997C29.5574 25.8439 29.5948 25.8883 29.633 25.9325ZM32.6235 28.1392C32.7142 28.1849 32.8223 28.1424 32.8647 28.0445C32.907 27.9463 32.8677 27.8299 32.777 27.7841C32.4527 27.6215 32.1381 27.4403 31.8347 27.2414C31.7491 27.1851 31.6375 27.2142 31.5853 27.3066C31.5331 27.3988 31.5602 27.5194 31.6458 27.5757C31.9605 27.7823 32.287 27.9704 32.6235 28.1392Z" fill="#E5E5E5"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2883 29.0967C35.3928 29.1234 35.4977 29.054 35.5225 28.9418C35.5472 28.8293 35.4828 28.7165 35.3783 28.6898C35.0044 28.5945 34.6348 28.4834 34.2703 28.3567C34.1683 28.3212 34.0587 28.3812 34.0254 28.4912C33.9923 28.601 34.0483 28.7189 34.1505 28.7545C34.5248 28.8846 34.9044 28.9987 35.2883 29.0965V29.0967ZM37.7828 29.48C37.8875 29.4875 37.9775 29.3959 37.9841 29.2753C37.9907 29.1545 37.9111 29.0508 37.8066 29.0432C37.4321 29.0161 37.0589 28.9735 36.6879 28.9155C36.584 28.8992 36.4885 28.9831 36.4744 29.1028C36.4604 29.2226 36.5329 29.3327 36.6369 29.349C37.0169 29.4085 37.3993 29.4522 37.783 29.48H37.7828ZM40.464 29.2169C40.568 29.2065 40.6417 29.1305 40.6286 29.0468C40.6158 28.9635 40.5211 28.9041 40.4174 28.9145C40.0551 28.9508 39.6817 28.975 39.3005 28.9867C39.1958 28.9899 39.1144 29.0608 39.1185 29.1449C39.1225 29.229 39.2105 29.2946 39.3151 29.2912C39.6988 29.2795 40.082 29.2548 40.464 29.2169ZM43.156 28.7148C43.2562 28.6732 43.3026 28.5606 43.2599 28.4631C43.2385 28.4159 43.1995 28.379 43.1513 28.3601C43.1031 28.3412 43.0494 28.3419 43.0016 28.362C42.6464 28.5079 42.2817 28.6294 41.9099 28.7254C41.8852 28.7315 41.862 28.7424 41.8416 28.7575C41.8212 28.7727 41.804 28.7918 41.7911 28.8136C41.7781 28.8355 41.7696 28.8597 41.7662 28.8849C41.7627 28.9101 41.7643 28.9357 41.7708 28.9603C41.7991 29.0627 41.9071 29.1234 42.0121 29.0958C42.4018 28.9951 42.784 28.8678 43.1562 28.7148H43.156ZM45.326 26.9135C45.3812 26.8313 45.354 26.7234 45.2656 26.6721C45.1767 26.6211 45.0601 26.6461 45.0049 26.7283C44.8241 26.9974 44.5845 27.2449 44.2899 27.4691C44.2709 27.4827 44.255 27.5003 44.2433 27.5205C44.2316 27.5407 44.2242 27.5632 44.2218 27.5864C44.2193 27.6097 44.2218 27.6332 44.229 27.6554C44.2362 27.6777 44.2481 27.6981 44.2638 27.7155C44.3299 27.7903 44.449 27.8011 44.5299 27.7397C44.8555 27.4921 45.1225 27.216 45.326 26.9135ZM46.8046 25.0747C46.8407 25.0429 46.8628 24.998 46.866 24.9499C46.8692 24.9018 46.8532 24.8544 46.8216 24.8181C46.8059 24.8001 46.7869 24.7854 46.7655 24.7748C46.7441 24.7642 46.7209 24.758 46.6971 24.7564C46.6733 24.7548 46.6494 24.7579 46.6268 24.7656C46.6043 24.7733 46.5834 24.7853 46.5655 24.8011C46.2859 25.0458 46.0227 25.3086 45.7775 25.5878C45.7458 25.6241 45.7297 25.6716 45.7329 25.7197C45.736 25.7678 45.7581 25.8127 45.7943 25.8446C45.8122 25.8604 45.833 25.8724 45.8556 25.8801C45.8781 25.8878 45.902 25.891 45.9257 25.8894C45.9495 25.8878 45.9728 25.8816 45.9941 25.871C46.0155 25.8604 46.0345 25.8458 46.0502 25.8278C46.2849 25.5606 46.5368 25.309 46.8044 25.0747H46.8046ZM49.036 23.5975C49.126 23.5489 49.1602 23.4354 49.1125 23.3439C49.1015 23.3222 49.0862 23.303 49.0676 23.2873C49.049 23.2716 49.0275 23.2598 49.0043 23.2526C48.9811 23.2453 48.9566 23.2428 48.9324 23.2451C48.9082 23.2474 48.8847 23.2545 48.8633 23.266C48.5322 23.4443 48.2091 23.6372 47.8952 23.8441C47.8099 23.9004 47.7856 24.0167 47.8409 24.1036C47.8965 24.1905 48.0106 24.2151 48.096 24.1588C48.4009 23.9579 48.7145 23.7706 49.036 23.5975ZM51.454 22.4735C51.5579 22.4415 51.6148 22.3367 51.581 22.239C51.5471 22.1411 51.4354 22.0878 51.3315 22.1195C50.9508 22.236 50.5742 22.3654 50.2024 22.5077C50.1011 22.5464 50.0523 22.6551 50.0935 22.7501C50.1347 22.8454 50.2504 22.8911 50.3517 22.8524C50.7147 22.7135 51.0823 22.5872 51.454 22.4735Z" fill="#EEEEEE"/>
-<path d="M27.7796 18.3307C28.3014 18.3307 28.7244 17.9076 28.7244 17.3858C28.7244 16.864 28.3014 16.4409 27.7796 16.4409C27.2577 16.4409 26.8347 16.864 26.8347 17.3858C26.8347 17.9076 27.2577 18.3307 27.7796 18.3307Z" fill="white" stroke="#EEEEEE"/>
-<path d="M45.3543 27.4016C45.8761 27.4016 46.2992 26.9786 46.2992 26.4567C46.2992 25.9349 45.8761 25.5118 45.3543 25.5118C44.8325 25.5118 44.4094 25.9349 44.4094 26.4567C44.4094 26.9786 44.8325 27.4016 45.3543 27.4016Z" fill="white" stroke="#EEEEEE"/>
-<path d="M4.16876 10.9607C4.16876 10.9607 0.867338 17.1969 1.90104 20.9764C2.93474 24.756 5.11364 27.0237 9.08214 29.2914C13.0506 31.5591 15.2125 28.3465 20.5984 30.4253C25.9842 32.504 26.0787 38.5513 26.0787 38.5513" stroke="#B5A7DD" stroke-width="0.4" stroke-linecap="round" stroke-dasharray="8 10"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M49.8898 47.6221C49.8898 39.5857 43.375 33.0709 35.3386 33.0709C27.3022 33.0709 20.7874 39.5857 20.7874 47.6221" fill="white"/>
-<path d="M49.8898 47.6221C49.8898 39.5857 43.375 33.0709 35.3386 33.0709C27.3022 33.0709 20.7874 39.5857 20.7874 47.6221" stroke="#EEEEEE" stroke-linecap="round"/>
-<path d="M41.1969 43.8425C42.8668 43.8425 44.2205 42.4888 44.2205 40.8189C44.2205 39.149 42.8668 37.7953 41.1969 37.7953C39.527 37.7953 38.1732 39.149 38.1732 40.8189C38.1732 42.4888 39.527 43.8425 41.1969 43.8425Z" stroke="#EEEEEE"/>
-<path d="M28.8189 40.441C29.7061 40.441 30.4252 39.7218 30.4252 38.8347C30.4252 37.9476 29.7061 37.2284 28.8189 37.2284C27.9318 37.2284 27.2126 37.9476 27.2126 38.8347C27.2126 39.7218 27.9318 40.441 28.8189 40.441Z" stroke="#EEEEEE"/>
-<path d="M24.9449 44.9764C25.4667 44.9764 25.8898 44.5534 25.8898 44.0316C25.8898 43.5097 25.4667 43.0867 24.9449 43.0867C24.423 43.0867 24 43.5097 24 44.0316C24 44.5534 24.423 44.9764 24.9449 44.9764Z" stroke="#EEEEEE"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M45.703 3.43286L46.1389 3.54966C46.346 2.7768 47.1318 2.3159 47.8939 2.5201L48.0123 2.078C47.0096 1.80933 45.9757 2.416 45.7032 3.43291L45.703 3.43286Z" fill="#FC8A51"/>
-<path d="M47.9471 2.61347C48.1478 2.66723 48.3546 2.54588 48.4091 2.34244C48.4636 2.139 48.3452 1.93051 48.1445 1.87675C47.9439 1.823 47.7371 1.94434 47.6826 2.14778C47.6281 2.35122 47.7465 2.55972 47.9471 2.61347Z" fill="#FC8A51"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M45.412 3.35511L44.9761 3.23831C45.1832 2.46545 44.7332 1.67342 43.9711 1.46921L44.0896 1.02711C45.0922 1.29577 45.6843 2.33814 45.4119 3.35506L45.412 3.35511Z" fill="#FC8A51"/>
-<path d="M43.8787 1.52354C43.6781 1.46979 43.5597 1.26129 43.6142 1.05785C43.6687 0.854411 43.8755 0.733068 44.0761 0.786823C44.2768 0.840578 44.3952 1.04908 44.3407 1.25252C44.2862 1.45596 44.0794 1.5773 43.8787 1.52354Z" fill="#FC8A51"/>
-<path d="M47.8422 7.79545L46.7544 7.50399C46.6536 7.47698 46.55 7.5368 46.523 7.63762C46.496 7.73843 46.5558 7.84205 46.6566 7.86907L47.7444 8.16052C47.8452 8.18754 47.9488 8.12771 47.9758 8.0269C48.0028 7.92608 47.943 7.82246 47.8422 7.79545Z" fill="#FC8A51"/>
-<path d="M47.4759 9.27595L46.5007 8.7129C46.4103 8.66071 46.2947 8.69168 46.2425 8.78207C46.1904 8.87245 46.2213 8.98803 46.3117 9.04021L47.287 9.60327C47.3773 9.65545 47.4929 9.62448 47.5451 9.5341C47.5973 9.44371 47.5663 9.32813 47.4759 9.27595Z" fill="#FC8A51"/>
-<path d="M48.1795 6.35876L47.0534 6.35876C46.949 6.35876 46.8644 6.44337 46.8644 6.54774C46.8644 6.65211 46.949 6.73671 47.0534 6.73671L48.1795 6.73671C48.2839 6.73671 48.3685 6.6521 48.3685 6.54774C48.3685 6.44337 48.2839 6.35876 48.1795 6.35876Z" fill="#FC8A51"/>
-<path d="M41.3783 6.06356L42.4661 6.35502C42.5669 6.38203 42.6267 6.48565 42.5997 6.58647C42.5727 6.68728 42.469 6.74711 42.3682 6.72009L41.2805 6.42863C41.1797 6.40162 41.1198 6.298 41.1469 6.19719C41.1739 6.09637 41.2775 6.03655 41.3783 6.06356Z" fill="#FC8A51"/>
-<path d="M40.9549 7.52856L42.081 7.52856C42.1854 7.52856 42.27 7.61317 42.27 7.71754C42.27 7.82191 42.1854 7.90651 42.081 7.90651L40.9549 7.90651C40.8506 7.90651 40.7659 7.8219 40.7659 7.71754C40.7659 7.61317 40.8505 7.52856 40.9549 7.52856Z" fill="#FC8A51"/>
-<path d="M41.8041 4.65032L42.7794 5.21337C42.8698 5.26556 42.9007 5.38113 42.8485 5.47152C42.7964 5.5619 42.6808 5.59287 42.5904 5.54069L41.6151 4.97763C41.5248 4.92545 41.4938 4.80987 41.546 4.71949C41.5982 4.6291 41.7137 4.59813 41.8041 4.65032Z" fill="#FC8A51"/>
-<path d="M47.3515 6.28652C47.7063 4.96254 46.9206 3.60168 45.5967 3.24693C44.2728 2.89219 42.912 3.6779 42.5572 5.00187L42.1051 6.68907C41.7504 8.01304 42.536 9.3739 43.86 9.72865C45.1839 10.0834 46.5447 9.29768 46.8995 7.97371L47.3515 6.28652Z" stroke="#FC8A51"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M42.7135 5.00037L47.0721 6.16826L46.9254 6.71587L42.5668 5.54798L42.7135 5.00037Z" fill="#FC8A51"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M17.4662 2.50359L17.1869 2.57842C17.1557 2.46241 17.1019 2.35369 17.0286 2.25847C16.9554 2.16325 16.8641 2.0834 16.7599 2.02348C16.6558 1.96355 16.5409 1.92474 16.4217 1.90924C16.3026 1.89374 16.1816 1.90187 16.0656 1.93316L15.9909 1.6546C16.634 1.48229 17.2944 1.86252 17.4662 2.50359Z" fill="#EEEEEE"/>
-<path d="M16.0312 1.99224C15.9025 2.02671 15.7704 1.95069 15.736 1.82246C15.7017 1.69423 15.7781 1.56233 15.9067 1.52786C16.0354 1.4934 16.1675 1.56941 16.2019 1.69764C16.2362 1.82587 16.1598 1.95777 16.0312 1.99224Z" fill="#EEEEEE"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6526 2.45366L17.9318 2.37883C17.9009 2.26274 17.8931 2.14169 17.909 2.02259C17.9248 1.9035 17.964 1.78869 18.0242 1.68473C18.0844 1.58076 18.1645 1.48969 18.2599 1.4167C18.3554 1.3437 18.4642 1.29023 18.5804 1.25932L18.5057 0.980773C17.8626 1.15308 17.4808 1.81259 17.6526 2.45366Z" fill="#EEEEEE"/>
-<path d="M18.6393 1.29344C18.7679 1.25898 18.8444 1.12708 18.81 0.998847C18.7756 0.870613 18.6435 0.794601 18.5149 0.82907C18.3862 0.863538 18.3098 0.995433 18.3442 1.12367C18.3785 1.2519 18.5107 1.32791 18.6393 1.29344Z" fill="#EEEEEE"/>
-<path d="M16.1505 5.24482L16.7167 5.0931C16.8175 5.06609 16.9211 5.12591 16.9481 5.22673C16.9752 5.32754 16.9153 5.43116 16.8145 5.45817L16.2483 5.60989C16.1475 5.63691 16.0439 5.57708 16.0168 5.47627C15.9898 5.37546 16.0497 5.27183 16.1505 5.24482Z" fill="#EEEEEE"/>
-<path d="M16.3578 6.1703L16.8655 5.8772C16.9559 5.82502 17.0714 5.85599 17.1236 5.94637C17.1758 6.03676 17.1448 6.15233 17.0545 6.20452L16.5468 6.49762C16.4564 6.5498 16.3408 6.51883 16.2886 6.42845C16.2365 6.33806 16.2674 6.22249 16.3578 6.1703Z" fill="#EEEEEE"/>
-<path d="M15.9573 4.35278L16.5435 4.35278C16.6479 4.35278 16.7325 4.43739 16.7325 4.54176C16.7325 4.64613 16.6479 4.73074 16.5435 4.73074L15.9573 4.73074C15.853 4.73074 15.7684 4.64613 15.7684 4.54176C15.7684 4.43739 15.853 4.35278 15.9573 4.35278Z" fill="#EEEEEE"/>
-<path d="M20.1626 4.16985L19.5964 4.32157C19.4956 4.34859 19.4357 4.45221 19.4628 4.55302C19.4898 4.65383 19.5934 4.71366 19.6942 4.68665L20.2604 4.53493C20.3612 4.50791 20.4211 4.40429 20.3941 4.30348C20.367 4.20267 20.2634 4.14284 20.1626 4.16985Z" fill="#EEEEEE"/>
-<path d="M20.4451 5.07507L19.8589 5.07507C19.7545 5.07507 19.6699 5.15968 19.6699 5.26405C19.6699 5.36842 19.7545 5.45303 19.8589 5.45303L20.4451 5.45303C20.5494 5.45303 20.634 5.36842 20.634 5.26405C20.634 5.15968 20.5494 5.07507 20.4451 5.07507Z" fill="#EEEEEE"/>
-<path d="M19.8834 3.30076L19.3757 3.59387C19.2853 3.64605 19.2543 3.76163 19.3065 3.85201C19.3587 3.9424 19.4743 3.97337 19.5647 3.92118L20.0723 3.62808C20.1627 3.5759 20.1937 3.46032 20.1415 3.36993C20.0893 3.27955 19.9737 3.24858 19.8834 3.30076Z" fill="#EEEEEE"/>
-<path d="M17.5341 2.38566L17.5343 2.38561C18.3829 2.15821 19.2552 2.66184 19.4826 3.51048L19.7541 4.52356C19.9815 5.3722 19.4778 6.24449 18.6292 6.47189L18.629 6.47193C17.7804 6.69933 16.9081 6.19571 16.6807 5.34707L16.4092 4.33398C16.1819 3.48534 16.6855 2.61305 17.5341 2.38566Z" stroke="#EEEEEE"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M19.3759 3.48474L16.582 4.23337L16.6798 4.59844L19.4737 3.84982L19.3759 3.48474Z" fill="#EEEEEE"/>
-<path d="M1.98426 23.0551C2.87139 23.0551 3.59056 22.336 3.59056 21.4488C3.59056 20.5617 2.87139 19.8425 1.98426 19.8425C1.09712 19.8425 0.37796 20.5617 0.37796 21.4488C0.37796 22.336 1.09712 23.0551 1.98426 23.0551Z" fill="white" stroke="#B5A7DD" stroke-width="0.4"/>
-<path d="M32.7874 24.9449C33.4658 24.9449 34.0158 24.3949 34.0158 23.7165C34.0158 23.0381 33.4658 22.4882 32.7874 22.4882C32.109 22.4882 31.5591 23.0381 31.5591 23.7165C31.5591 24.3949 32.109 24.9449 32.7874 24.9449Z" fill="white"/>
-<path d="M10.1102 31.9371C11.4148 31.9371 12.4724 30.8795 12.4724 29.5749C12.4724 28.2702 11.4148 27.2126 10.1102 27.2126C8.80561 27.2126 7.74802 28.2702 7.74802 29.5749C7.74802 30.8795 8.80561 31.9371 10.1102 31.9371Z" fill="white" stroke="#6B4FBB"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M9.77635 29.759L9.44262 29.4251C9.38944 29.3719 9.31732 29.3421 9.24211 29.3421C9.16691 29.3421 9.09479 29.3719 9.04161 29.4251C8.98843 29.4783 8.95856 29.5504 8.95856 29.6256C8.95856 29.7008 8.98843 29.773 9.04161 29.8261L9.57566 30.3602H9.57585V30.3604C9.68734 30.4719 9.86592 30.4713 9.97629 30.3609L11.1801 29.1572C11.2328 29.1038 11.2623 29.0319 11.2622 28.9569C11.2621 28.8819 11.2324 28.8101 11.1795 28.7569C11.1533 28.7305 11.1222 28.7095 11.0879 28.6952C11.0535 28.6808 11.0167 28.6734 10.9795 28.6733C10.9423 28.6733 10.9055 28.6805 10.8711 28.6948C10.8368 28.709 10.8055 28.7298 10.7792 28.7561L9.77635 29.759Z" fill="#FC8A51"/>
-<path d="M32.7874 24.7559C33.4658 24.7559 34.0158 24.2059 34.0158 23.5275C34.0158 22.8491 33.4658 22.2992 32.7874 22.2992C32.109 22.2992 31.5591 22.8491 31.5591 23.5275C31.5591 24.2059 32.109 24.7559 32.7874 24.7559Z" fill="white" stroke="#FC8A51"/>
-<path d="M4.53541 11.3386C5.16162 11.3386 5.66926 10.831 5.66926 10.2048C5.66926 9.57857 5.16162 9.07092 4.53541 9.07092C3.9092 9.07092 3.40155 9.57857 3.40155 10.2048C3.40155 10.831 3.9092 11.3386 4.53541 11.3386Z" fill="white" stroke="#B5A7DD" stroke-width="0.4"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M35.9339 27.1363C37.5787 25.6725 38.0151 23.182 36.8527 21.1688C35.5261 18.8708 32.6187 18.0654 30.3591 19.3701C28.0993 20.6748 27.343 23.5953 28.6698 25.8932C29.8483 27.9342 32.2732 28.7978 34.3841 28.0513L36.4437 31.619C36.563 31.8259 36.7596 31.9769 36.9903 32.0389C37.2209 32.1008 37.4667 32.0686 37.6736 31.9493C37.8802 31.8297 38.0309 31.6329 38.0926 31.4023C38.1542 31.1716 38.1218 30.9259 38.0024 30.7191L35.9339 27.1363ZM34.34 26.2653C35.8248 25.4081 36.3218 23.4889 35.4499 21.9788C34.5782 20.4686 32.6676 19.9395 31.1828 20.7967C29.6978 21.6541 29.2008 23.5733 30.0726 25.0834C30.9445 26.5934 32.8551 27.1227 34.34 26.2653Z" fill="white" stroke="#B5A7DD" stroke-width="0.5"/>
-</g>
-<defs>
-<clipPath id="clip0">
-<rect width="80.315" height="48" fill="white"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/app/assets/images/learn_gitlab/merge_request_created.svg b/app/assets/images/learn_gitlab/merge_request_created.svg
deleted file mode 100644
index b8137a60f06..00000000000
--- a/app/assets/images/learn_gitlab/merge_request_created.svg
+++ /dev/null
@@ -1,107 +0,0 @@
-<svg width="79" height="47" viewBox="0 0 79 47" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#clip0)">
-<path d="M27.0655 1.96289H5.80354C4.48549 1.96289 3.41699 3.03139 3.41699 4.34944C3.41699 5.6675 4.48549 6.73599 5.80354 6.73599H27.0655C28.3836 6.73599 29.4521 5.6675 29.4521 4.34944C29.4521 3.03139 28.3836 1.96289 27.0655 1.96289Z" fill="#F9F9F9"/>
-<path d="M23.1603 11.5092H-0.705247C-2.0233 11.5092 -3.0918 12.5776 -3.0918 13.8957C-3.0918 15.2138 -2.0233 16.2823 -0.705247 16.2823H23.1603C24.4783 16.2823 25.5468 15.2138 25.5468 13.8957C25.5468 12.5776 24.4783 11.5092 23.1603 11.5092Z" fill="#F9F9F9"/>
-<path d="M80.8713 16.2822H44.4222C43.1041 16.2822 42.0356 17.3507 42.0356 18.6688C42.0356 19.9868 43.1041 21.0553 44.4222 21.0553H80.8713C82.1894 21.0553 83.2579 19.9868 83.2579 18.6688C83.2579 17.3507 82.1894 16.2822 80.8713 16.2822Z" fill="#F9F9F9"/>
-<path d="M56.789 44.7039H27.2825C25.9645 44.7039 24.896 45.7724 24.896 47.0904C24.896 48.4085 25.9645 49.477 27.2825 49.477H56.789C58.107 49.477 59.1755 48.4085 59.1755 47.0904C59.1755 45.7724 58.107 44.7039 56.789 44.7039Z" fill="#F9F9F9"/>
-<path d="M43.1205 35.3746H13.6141C12.296 35.3746 11.2275 36.4431 11.2275 37.7612C11.2275 39.0792 12.296 40.1477 13.6141 40.1477H43.1205C44.4386 40.1477 45.5071 39.0792 45.5071 37.7612C45.5071 36.4431 44.4386 35.3746 43.1205 35.3746Z" fill="#F9F9F9"/>
-<path d="M77.1829 25.8284H6.02034C4.70228 25.8284 3.63379 26.8969 3.63379 28.2149C3.63379 29.533 4.70228 30.6015 6.02034 30.6015H77.1829C78.501 30.6015 79.5695 29.533 79.5695 28.2149C79.5695 26.8969 78.501 25.8284 77.1829 25.8284Z" fill="#F9F9F9"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M30.103 6.73596H1.46436H6.12898C7.43073 6.73596 8.51553 7.82076 8.51553 9.12251C8.51553 10.4243 7.43073 11.5091 6.12898 11.5091H1.46436H30.103H22.1839C20.8822 11.5091 19.7974 10.4243 19.7974 9.12251C19.7974 7.82076 20.8822 6.73596 22.1839 6.73596H30.103ZM84.7766 21.0553H59.3924H67.3114C68.6132 21.0553 69.698 22.1401 69.698 23.4418C69.698 24.7436 68.6132 25.8284 67.3114 25.8284H59.3924H84.7766H76.8576C75.5559 25.8284 74.4711 24.7436 74.4711 23.4418C74.4711 22.1401 75.5559 21.0553 76.8576 21.0553H84.7766ZM31.8386 30.6015H6.45441H14.3734C15.6752 30.6015 16.76 31.6862 16.76 32.988C16.76 34.2898 15.6752 35.3746 14.3734 35.3746H6.45441H31.8386H23.9196C22.6179 35.3746 21.5331 34.2898 21.5331 32.988C21.5331 31.6862 22.6179 30.6015 23.9196 30.6015H31.8386ZM48.1106 40.1477H22.7263H27.391C28.6927 40.1477 29.7775 41.2324 29.7775 42.5342C29.7775 43.836 28.6927 44.9207 27.391 44.9207H22.7263H48.1106H36.9372C35.6354 44.9207 34.5506 43.836 34.5506 42.5342C34.5506 41.2324 35.6354 40.1477 36.9372 40.1477H48.1106Z" fill="#F9F9F9"/>
-<path d="M68.0708 4.78333H12.0954C10.8971 4.78333 9.92578 5.75468 9.92578 6.95292V41.4494C9.92578 42.6476 10.8971 43.619 12.0954 43.619H68.0708C69.269 43.619 70.2404 42.6476 70.2404 41.4494V6.95292C70.2404 5.75468 69.269 4.78333 68.0708 4.78333Z" fill="white" stroke="#EEEEEE" stroke-width="2"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M10.7935 11.0751H69.5894V11.9429H10.7935V11.0751Z" fill="#EEEEEE"/>
-<path d="M18.387 18.0178H17.5192C17.3994 18.0178 17.3022 18.115 17.3022 18.2348C17.3022 18.3546 17.3994 18.4517 17.5192 18.4517H18.387C18.5069 18.4517 18.604 18.3546 18.604 18.2348C18.604 18.115 18.5069 18.0178 18.387 18.0178Z" fill="#B5A7DD"/>
-<path d="M23.3771 17.8009H20.9906C20.7509 17.8009 20.5566 17.9952 20.5566 18.2348C20.5566 18.4745 20.7509 18.6687 20.9906 18.6687H23.3771C23.6167 18.6687 23.811 18.4745 23.811 18.2348C23.811 17.9952 23.6167 17.8009 23.3771 17.8009Z" fill="#EEEEEE"/>
-<path d="M35.7438 17.8009H33.3573C33.1176 17.8009 32.9233 17.9952 32.9233 18.2348C32.9233 18.4745 33.1176 18.6687 33.3573 18.6687H35.7438C35.9835 18.6687 36.1777 18.4745 36.1777 18.2348C36.1777 17.9952 35.9835 17.8009 35.7438 17.8009Z" fill="#EEEEEE"/>
-<path d="M28.5841 22.574H26.1976C25.9579 22.574 25.7637 22.7682 25.7637 23.0079C25.7637 23.2475 25.9579 23.4418 26.1976 23.4418H28.5841C28.8238 23.4418 29.0181 23.2475 29.0181 23.0079C29.0181 22.7682 28.8238 22.574 28.5841 22.574Z" fill="#EEEEEE"/>
-<path d="M31.6217 20.1875H29.2352C28.9955 20.1875 28.8013 20.3818 28.8013 20.6214C28.8013 20.8611 28.9955 21.0553 29.2352 21.0553H31.6217C31.8614 21.0553 32.0557 20.8611 32.0557 20.6214C32.0557 20.3818 31.8614 20.1875 31.6217 20.1875Z" fill="#FC6D26"/>
-<path opacity="0.5" d="M31.6216 17.8009H28.1502C27.9106 17.8009 27.7163 17.9952 27.7163 18.2348C27.7163 18.4745 27.9106 18.6687 28.1502 18.6687H31.6216C31.8612 18.6687 32.0555 18.4745 32.0555 18.2348C32.0555 17.9952 31.8612 17.8009 31.6216 17.8009Z" fill="#FC6D26"/>
-<path d="M24.4619 22.574H20.9906C20.7509 22.574 20.5566 22.7682 20.5566 23.0079C20.5566 23.2475 20.7509 23.4418 20.9906 23.4418H24.4619C24.7016 23.4418 24.8958 23.2475 24.8958 23.0079C24.8958 22.7682 24.7016 22.574 24.4619 22.574Z" fill="#EEEEEE"/>
-<path d="M27.4995 20.1875H24.0282C23.7885 20.1875 23.5942 20.3818 23.5942 20.6214C23.5942 20.8611 23.7885 21.0553 24.0282 21.0553H27.4995C27.7392 21.0553 27.9334 20.8611 27.9334 20.6214C27.9334 20.3818 27.7392 20.1875 27.4995 20.1875Z" fill="#EEEEEE"/>
-<path d="M26.4144 17.8009H25.1126C24.873 17.8009 24.6787 17.9952 24.6787 18.2348C24.6787 18.4745 24.873 18.6687 25.1126 18.6687H26.4144C26.654 18.6687 26.8483 18.4745 26.8483 18.2348C26.8483 17.9952 26.654 17.8009 26.4144 17.8009Z" fill="#FC6D26"/>
-<path d="M22.2923 20.1875H20.9906C20.7509 20.1875 20.5566 20.3818 20.5566 20.6214C20.5566 20.8611 20.7509 21.0553 20.9906 21.0553H22.2923C22.532 21.0553 22.7262 20.8611 22.7262 20.6214C22.7262 20.3818 22.532 20.1875 22.2923 20.1875Z" fill="#EEEEEE"/>
-<path d="M18.387 20.4044H17.5192C17.3994 20.4044 17.3022 20.5016 17.3022 20.6214C17.3022 20.7412 17.3994 20.8383 17.5192 20.8383H18.387C18.5069 20.8383 18.604 20.7412 18.604 20.6214C18.604 20.5016 18.5069 20.4044 18.387 20.4044Z" fill="#B5A7DD"/>
-<path d="M18.387 22.791H17.5192C17.3994 22.791 17.3022 22.8882 17.3022 23.008C17.3022 23.1278 17.3994 23.2249 17.5192 23.2249H18.387C18.5069 23.2249 18.604 23.1278 18.604 23.008C18.604 22.8882 18.5069 22.791 18.387 22.791Z" fill="#B5A7DD"/>
-<path d="M18.387 25.1774H17.5192C17.3994 25.1774 17.3022 25.2745 17.3022 25.3943C17.3022 25.5142 17.3994 25.6113 17.5192 25.6113H18.387C18.5069 25.6113 18.604 25.5142 18.604 25.3943C18.604 25.2745 18.5069 25.1774 18.387 25.1774Z" fill="#B5A7DD"/>
-<path d="M23.3771 24.9604H20.9906C20.7509 24.9604 20.5566 25.1547 20.5566 25.3944C20.5566 25.634 20.7509 25.8283 20.9906 25.8283H23.3771C23.6167 25.8283 23.811 25.634 23.811 25.3944C23.811 25.1547 23.6167 24.9604 23.3771 24.9604Z" fill="#FC6D26"/>
-<path d="M35.7438 24.9604H33.3573C33.1176 24.9604 32.9233 25.1547 32.9233 25.3944C32.9233 25.634 33.1176 25.8283 33.3573 25.8283H35.7438C35.9835 25.8283 36.1777 25.634 36.1777 25.3944C36.1777 25.1547 35.9835 24.9604 35.7438 24.9604Z" fill="#EEEEEE"/>
-<path opacity="0.5" d="M28.5841 29.7335H26.1976C25.9579 29.7335 25.7637 29.9278 25.7637 30.1674C25.7637 30.4071 25.9579 30.6014 26.1976 30.6014H28.5841C28.8238 30.6014 29.0181 30.4071 29.0181 30.1674C29.0181 29.9278 28.8238 29.7335 28.5841 29.7335Z" fill="#FC6D26"/>
-<path d="M31.6217 27.347H29.2352C28.9955 27.347 28.8013 27.5413 28.8013 27.781C28.8013 28.0206 28.9955 28.2149 29.2352 28.2149H31.6217C31.8614 28.2149 32.0557 28.0206 32.0557 27.781C32.0557 27.5413 31.8614 27.347 31.6217 27.347Z" fill="#EEEEEE"/>
-<path d="M31.6216 24.9604H28.1502C27.9106 24.9604 27.7163 25.1547 27.7163 25.3944C27.7163 25.634 27.9106 25.8283 28.1502 25.8283H31.6216C31.8612 25.8283 32.0555 25.634 32.0555 25.3944C32.0555 25.1547 31.8612 24.9604 31.6216 24.9604Z" fill="#FC6D26"/>
-<path d="M24.4619 29.7335H20.9906C20.7509 29.7335 20.5566 29.9278 20.5566 30.1674C20.5566 30.4071 20.7509 30.6014 20.9906 30.6014H24.4619C24.7016 30.6014 24.8958 30.4071 24.8958 30.1674C24.8958 29.9278 24.7016 29.7335 24.4619 29.7335Z" fill="#FC6D26"/>
-<path d="M27.4995 27.347H24.0282C23.7885 27.347 23.5942 27.5413 23.5942 27.781C23.5942 28.0206 23.7885 28.2149 24.0282 28.2149H27.4995C27.7392 28.2149 27.9334 28.0206 27.9334 27.781C27.9334 27.5413 27.7392 27.347 27.4995 27.347Z" fill="#EEEEEE"/>
-<path opacity="0.5" d="M26.4144 24.9604H25.1126C24.873 24.9604 24.6787 25.1547 24.6787 25.3944C24.6787 25.634 24.873 25.8283 25.1126 25.8283H26.4144C26.654 25.8283 26.8483 25.634 26.8483 25.3944C26.8483 25.1547 26.654 24.9604 26.4144 24.9604Z" fill="#FC6D26"/>
-<path d="M22.2923 27.347H20.9906C20.7509 27.347 20.5566 27.5413 20.5566 27.781C20.5566 28.0206 20.7509 28.2149 20.9906 28.2149H22.2923C22.532 28.2149 22.7262 28.0206 22.7262 27.781C22.7262 27.5413 22.532 27.347 22.2923 27.347Z" fill="#EEEEEE"/>
-<path d="M18.387 27.564H17.5192C17.3994 27.564 17.3022 27.6611 17.3022 27.7809C17.3022 27.9007 17.3994 27.9979 17.5192 27.9979H18.387C18.5069 27.9979 18.604 27.9007 18.604 27.7809C18.604 27.6611 18.5069 27.564 18.387 27.564Z" fill="#B5A7DD"/>
-<path d="M18.387 29.9506H17.5192C17.3994 29.9506 17.3022 30.0477 17.3022 30.1675C17.3022 30.2873 17.3994 30.3845 17.5192 30.3845H18.387C18.5069 30.3845 18.604 30.2873 18.604 30.1675C18.604 30.0477 18.5069 29.9506 18.387 29.9506Z" fill="#B5A7DD"/>
-<path d="M18.387 32.337H17.5192C17.3994 32.337 17.3022 32.4342 17.3022 32.554C17.3022 32.6738 17.3994 32.771 17.5192 32.771H18.387C18.5069 32.771 18.604 32.6738 18.604 32.554C18.604 32.4342 18.5069 32.337 18.387 32.337Z" fill="#B5A7DD"/>
-<path d="M23.3771 32.1201H20.9906C20.7509 32.1201 20.5566 32.3144 20.5566 32.554C20.5566 32.7937 20.7509 32.988 20.9906 32.988H23.3771C23.6167 32.988 23.811 32.7937 23.811 32.554C23.811 32.3144 23.6167 32.1201 23.3771 32.1201Z" fill="#EEEEEE"/>
-<path d="M35.7438 32.1201H33.3573C33.1176 32.1201 32.9233 32.3144 32.9233 32.554C32.9233 32.7937 33.1176 32.988 33.3573 32.988H35.7438C35.9835 32.988 36.1777 32.7937 36.1777 32.554C36.1777 32.3144 35.9835 32.1201 35.7438 32.1201Z" fill="#EEEEEE"/>
-<path d="M28.5841 36.8932H26.1976C25.9579 36.8932 25.7637 37.0875 25.7637 37.3271C25.7637 37.5668 25.9579 37.761 26.1976 37.761H28.5841C28.8238 37.761 29.0181 37.5668 29.0181 37.3271C29.0181 37.0875 28.8238 36.8932 28.5841 36.8932Z" fill="#EEEEEE"/>
-<path d="M31.6217 34.5067H29.2352C28.9955 34.5067 28.8013 34.701 28.8013 34.9406C28.8013 35.1803 28.9955 35.3745 29.2352 35.3745H31.6217C31.8614 35.3745 32.0557 35.1803 32.0557 34.9406C32.0557 34.701 31.8614 34.5067 31.6217 34.5067Z" fill="#EEEEEE"/>
-<path d="M31.6216 32.1201H28.1502C27.9106 32.1201 27.7163 32.3144 27.7163 32.554C27.7163 32.7937 27.9106 32.988 28.1502 32.988H31.6216C31.8612 32.988 32.0555 32.7937 32.0555 32.554C32.0555 32.3144 31.8612 32.1201 31.6216 32.1201Z" fill="#FC6D26"/>
-<path d="M24.4619 36.8932H20.9906C20.7509 36.8932 20.5566 37.0875 20.5566 37.3271C20.5566 37.5668 20.7509 37.761 20.9906 37.761H24.4619C24.7016 37.761 24.8958 37.5668 24.8958 37.3271C24.8958 37.0875 24.7016 36.8932 24.4619 36.8932Z" fill="#EEEEEE"/>
-<path d="M27.4995 34.5067H24.0282C23.7885 34.5067 23.5942 34.701 23.5942 34.9406C23.5942 35.1803 23.7885 35.3745 24.0282 35.3745H27.4995C27.7392 35.3745 27.9334 35.1803 27.9334 34.9406C27.9334 34.701 27.7392 34.5067 27.4995 34.5067Z" fill="#EEEEEE"/>
-<path opacity="0.5" d="M26.4144 32.1201H25.1126C24.873 32.1201 24.6787 32.3144 24.6787 32.554C24.6787 32.7937 24.873 32.988 25.1126 32.988H26.4144C26.654 32.988 26.8483 32.7937 26.8483 32.554C26.8483 32.3144 26.654 32.1201 26.4144 32.1201Z" fill="#FC6D26"/>
-<path d="M22.2923 34.5067H20.9906C20.7509 34.5067 20.5566 34.701 20.5566 34.9406C20.5566 35.1803 20.7509 35.3745 20.9906 35.3745H22.2923C22.532 35.3745 22.7262 35.1803 22.7262 34.9406C22.7262 34.701 22.532 34.5067 22.2923 34.5067Z" fill="#EEEEEE"/>
-<path d="M18.387 34.7236H17.5192C17.3994 34.7236 17.3022 34.8208 17.3022 34.9406C17.3022 35.0604 17.3994 35.1575 17.5192 35.1575H18.387C18.5069 35.1575 18.604 35.0604 18.604 34.9406C18.604 34.8208 18.5069 34.7236 18.387 34.7236Z" fill="#B5A7DD"/>
-<path d="M18.387 37.1102H17.5192C17.3994 37.1102 17.3022 37.2074 17.3022 37.3272C17.3022 37.447 17.3994 37.5442 17.5192 37.5442H18.387C18.5069 37.5442 18.604 37.447 18.604 37.3272C18.604 37.2074 18.5069 37.1102 18.387 37.1102Z" fill="#B5A7DD"/>
-<path d="M45.073 17.8008H44.2052C44.0854 17.8008 43.9883 17.8979 43.9883 18.0177C43.9883 18.1376 44.0854 18.2347 44.2052 18.2347H45.073C45.1929 18.2347 45.29 18.1376 45.29 18.0177C45.29 17.8979 45.1929 17.8008 45.073 17.8008Z" fill="#FDE5D8"/>
-<path d="M50.0631 17.5839H47.6766C47.4369 17.5839 47.2427 17.7781 47.2427 18.0178C47.2427 18.2574 47.4369 18.4517 47.6766 18.4517H50.0631C50.3028 18.4517 50.4971 18.2574 50.4971 18.0178C50.4971 17.7781 50.3028 17.5839 50.0631 17.5839Z" fill="#EEEEEE"/>
-<path d="M62.4299 17.5839H60.0433C59.8036 17.5839 59.6094 17.7781 59.6094 18.0178C59.6094 18.2574 59.8036 18.4517 60.0433 18.4517H62.4299C62.6695 18.4517 62.8638 18.2574 62.8638 18.0178C62.8638 17.7781 62.6695 17.5839 62.4299 17.5839Z" fill="#EEEEEE"/>
-<path opacity="0.5" d="M55.2702 22.3569H52.8836C52.644 22.3569 52.4497 22.5512 52.4497 22.7909C52.4497 23.0305 52.644 23.2248 52.8836 23.2248H55.2702C55.5098 23.2248 55.7041 23.0305 55.7041 22.7909C55.7041 22.5512 55.5098 22.3569 55.2702 22.3569Z" fill="#6B4FBB"/>
-<path d="M58.3078 19.9705H55.9212C55.6816 19.9705 55.4873 20.1647 55.4873 20.4044C55.4873 20.644 55.6816 20.8383 55.9212 20.8383H58.3078C58.5474 20.8383 58.7417 20.644 58.7417 20.4044C58.7417 20.1647 58.5474 19.9705 58.3078 19.9705Z" fill="#6B4FBB"/>
-<path opacity="0.5" d="M58.3076 17.5839H54.8363C54.5966 17.5839 54.4023 17.7781 54.4023 18.0178C54.4023 18.2574 54.5966 18.4517 54.8363 18.4517H58.3076C58.5473 18.4517 58.7415 18.2574 58.7415 18.0178C58.7415 17.7781 58.5473 17.5839 58.3076 17.5839Z" fill="#6B4FBB"/>
-<path d="M51.1479 22.3569H47.6766C47.4369 22.3569 47.2427 22.5512 47.2427 22.7909C47.2427 23.0305 47.4369 23.2248 47.6766 23.2248H51.1479C51.3876 23.2248 51.5819 23.0305 51.5819 22.7909C51.5819 22.5512 51.3876 22.3569 51.1479 22.3569Z" fill="#6B4FBB"/>
-<path d="M54.1855 19.9705H50.7142C50.4745 19.9705 50.2803 20.1647 50.2803 20.4044C50.2803 20.644 50.4745 20.8383 50.7142 20.8383H54.1855C54.4252 20.8383 54.6195 20.644 54.6195 20.4044C54.6195 20.1647 54.4252 19.9705 54.1855 19.9705Z" fill="#EEEEEE"/>
-<path d="M53.1004 17.5839H51.7987C51.559 17.5839 51.3647 17.7781 51.3647 18.0178C51.3647 18.2574 51.559 18.4517 51.7987 18.4517H53.1004C53.3401 18.4517 53.5343 18.2574 53.5343 18.0178C53.5343 17.7781 53.3401 17.5839 53.1004 17.5839Z" fill="#6B4FBB"/>
-<path d="M48.9783 19.9705H47.6766C47.4369 19.9705 47.2427 20.1647 47.2427 20.4044C47.2427 20.644 47.4369 20.8383 47.6766 20.8383H48.9783C49.218 20.8383 49.4123 20.644 49.4123 20.4044C49.4123 20.1647 49.218 19.9705 48.9783 19.9705Z" fill="#EEEEEE"/>
-<path d="M45.073 20.1874H44.2052C44.0854 20.1874 43.9883 20.2845 43.9883 20.4043C43.9883 20.5242 44.0854 20.6213 44.2052 20.6213H45.073C45.1929 20.6213 45.29 20.5242 45.29 20.4043C45.29 20.2845 45.1929 20.1874 45.073 20.1874Z" fill="#FDE5D8"/>
-<path d="M45.073 22.574H44.2052C44.0854 22.574 43.9883 22.6711 43.9883 22.7909C43.9883 22.9108 44.0854 23.0079 44.2052 23.0079H45.073C45.1929 23.0079 45.29 22.9108 45.29 22.7909C45.29 22.6711 45.1929 22.574 45.073 22.574Z" fill="#FDE5D8"/>
-<path d="M45.073 24.9604H44.2052C44.0854 24.9604 43.9883 25.0576 43.9883 25.1774C43.9883 25.2972 44.0854 25.3944 44.2052 25.3944H45.073C45.1929 25.3944 45.29 25.2972 45.29 25.1774C45.29 25.0576 45.1929 24.9604 45.073 24.9604Z" fill="#FDE5D8"/>
-<path d="M50.0631 24.7435H47.6766C47.4369 24.7435 47.2427 24.9378 47.2427 25.1774C47.2427 25.4171 47.4369 25.6114 47.6766 25.6114H50.0631C50.3028 25.6114 50.4971 25.4171 50.4971 25.1774C50.4971 24.9378 50.3028 24.7435 50.0631 24.7435Z" fill="#EEEEEE"/>
-<path d="M59.3922 22.3569H57.0057C56.766 22.3569 56.5718 22.5512 56.5718 22.7909C56.5718 23.0305 56.766 23.2248 57.0057 23.2248H59.3922C59.6319 23.2248 59.8262 23.0305 59.8262 22.7909C59.8262 22.5512 59.6319 22.3569 59.3922 22.3569Z" fill="#EEEEEE"/>
-<path opacity="0.5" d="M55.2702 29.5166H52.8836C52.644 29.5166 52.4497 29.7109 52.4497 29.9505C52.4497 30.1902 52.644 30.3844 52.8836 30.3844H55.2702C55.5098 30.3844 55.7041 30.1902 55.7041 29.9505C55.7041 29.7109 55.5098 29.5166 55.2702 29.5166Z" fill="#6B4FBB"/>
-<path d="M53.1007 27.1301H50.7142C50.4745 27.1301 50.2803 27.3244 50.2803 27.564C50.2803 27.8037 50.4745 27.998 50.7142 27.998H53.1007C53.3404 27.998 53.5347 27.8037 53.5347 27.564C53.5347 27.3244 53.3404 27.1301 53.1007 27.1301Z" fill="#6B4FBB"/>
-<path d="M58.3076 24.7435H54.8363C54.5966 24.7435 54.4023 24.9378 54.4023 25.1774C54.4023 25.4171 54.5966 25.6114 54.8363 25.6114H58.3076C58.5473 25.6114 58.7415 25.4171 58.7415 25.1774C58.7415 24.9378 58.5473 24.7435 58.3076 24.7435Z" fill="#6B4FBB"/>
-<path d="M51.1479 29.5166H47.6766C47.4369 29.5166 47.2427 29.7109 47.2427 29.9505C47.2427 30.1902 47.4369 30.3844 47.6766 30.3844H51.1479C51.3876 30.3844 51.5819 30.1902 51.5819 29.9505C51.5819 29.7109 51.3876 29.5166 51.1479 29.5166Z" fill="#EEEEEE"/>
-<path d="M53.1004 24.7435H51.7987C51.559 24.7435 51.3647 24.9378 51.3647 25.1774C51.3647 25.4171 51.559 25.6114 51.7987 25.6114H53.1004C53.3401 25.6114 53.5343 25.4171 53.5343 25.1774C53.5343 24.9378 53.3401 24.7435 53.1004 24.7435Z" fill="#EEEEEE"/>
-<path d="M48.9783 27.1301H47.6766C47.4369 27.1301 47.2427 27.3244 47.2427 27.564C47.2427 27.8037 47.4369 27.998 47.6766 27.998H48.9783C49.218 27.998 49.4123 27.8037 49.4123 27.564C49.4123 27.3244 49.218 27.1301 48.9783 27.1301Z" fill="#EEEEEE"/>
-<path d="M56.138 27.1301H54.8363C54.5966 27.1301 54.4023 27.3244 54.4023 27.564C54.4023 27.8037 54.5966 27.998 54.8363 27.998H56.138C56.3777 27.998 56.5719 27.8037 56.5719 27.564C56.5719 27.3244 56.3777 27.1301 56.138 27.1301Z" fill="#EEEEEE"/>
-<path d="M59.1756 27.1301H57.8739C57.6342 27.1301 57.4399 27.3244 57.4399 27.564C57.4399 27.8037 57.6342 27.998 57.8739 27.998H59.1756C59.4153 27.998 59.6095 27.8037 59.6095 27.564C59.6095 27.3244 59.4153 27.1301 59.1756 27.1301Z" fill="#EEEEEE"/>
-<path d="M62.43 22.3569H61.1283C60.8886 22.3569 60.6943 22.5512 60.6943 22.7909C60.6943 23.0305 60.8886 23.2248 61.1283 23.2248H62.43C62.6697 23.2248 62.8639 23.0305 62.8639 22.7909C62.8639 22.5512 62.6697 22.3569 62.43 22.3569Z" fill="#EEEEEE"/>
-<path d="M45.073 27.347H44.2052C44.0854 27.347 43.9883 27.4442 43.9883 27.564C43.9883 27.6838 44.0854 27.781 44.2052 27.781H45.073C45.1929 27.781 45.29 27.6838 45.29 27.564C45.29 27.4442 45.1929 27.347 45.073 27.347Z" fill="#FDE5D8"/>
-<path d="M45.073 29.7335H44.2052C44.0854 29.7335 43.9883 29.8307 43.9883 29.9505C43.9883 30.0703 44.0854 30.1674 44.2052 30.1674H45.073C45.1929 30.1674 45.29 30.0703 45.29 29.9505C45.29 29.8307 45.1929 29.7335 45.073 29.7335Z" fill="#FDE5D8"/>
-<path d="M45.073 32.1201H44.2052C44.0854 32.1201 43.9883 32.2173 43.9883 32.3371C43.9883 32.4569 44.0854 32.554 44.2052 32.554H45.073C45.1929 32.554 45.29 32.4569 45.29 32.3371C45.29 32.2173 45.1929 32.1201 45.073 32.1201Z" fill="#FDE5D8"/>
-<path d="M50.0631 31.9032H47.6766C47.4369 31.9032 47.2427 32.0975 47.2427 32.3371C47.2427 32.5768 47.4369 32.771 47.6766 32.771H50.0631C50.3028 32.771 50.4971 32.5768 50.4971 32.3371C50.4971 32.0975 50.3028 31.9032 50.0631 31.9032Z" fill="#6B4FBB"/>
-<path d="M55.2702 36.6763H52.8836C52.644 36.6763 52.4497 36.8705 52.4497 37.1102C52.4497 37.3498 52.644 37.5441 52.8836 37.5441H55.2702C55.5098 37.5441 55.7041 37.3498 55.7041 37.1102C55.7041 36.8705 55.5098 36.6763 55.2702 36.6763Z" fill="#EEEEEE"/>
-<path opacity="0.5" d="M58.3078 34.2897H55.9212C55.6816 34.2897 55.4873 34.4839 55.4873 34.7236C55.4873 34.9632 55.6816 35.1575 55.9212 35.1575H58.3078C58.5474 35.1575 58.7417 34.9632 58.7417 34.7236C58.7417 34.4839 58.5474 34.2897 58.3078 34.2897Z" fill="#6B4FBB"/>
-<path d="M51.1479 36.6763H47.6766C47.4369 36.6763 47.2427 36.8705 47.2427 37.1102C47.2427 37.3498 47.4369 37.5441 47.6766 37.5441H51.1479C51.3876 37.5441 51.5819 37.3498 51.5819 37.1102C51.5819 36.8705 51.3876 36.6763 51.1479 36.6763Z" fill="#EEEEEE"/>
-<path d="M54.1855 34.2897H50.7142C50.4745 34.2897 50.2803 34.4839 50.2803 34.7236C50.2803 34.9632 50.4745 35.1575 50.7142 35.1575H54.1855C54.4252 35.1575 54.6195 34.9632 54.6195 34.7236C54.6195 34.4839 54.4252 34.2897 54.1855 34.2897Z" fill="#6B4FBB"/>
-<path d="M53.1004 31.9032H51.7987C51.559 31.9032 51.3647 32.0975 51.3647 32.3371C51.3647 32.5768 51.559 32.771 51.7987 32.771H53.1004C53.3401 32.771 53.5343 32.5768 53.5343 32.3371C53.5343 32.0975 53.3401 31.9032 53.1004 31.9032Z" fill="#EEEEEE"/>
-<path d="M61.3451 34.2897H60.0433C59.8036 34.2897 59.6094 34.4839 59.6094 34.7236C59.6094 34.9632 59.8036 35.1575 60.0433 35.1575H61.3451C61.5847 35.1575 61.779 34.9632 61.779 34.7236C61.779 34.4839 61.5847 34.2897 61.3451 34.2897Z" fill="#EEEEEE"/>
-<path d="M48.9783 34.2897H47.6766C47.4369 34.2897 47.2427 34.4839 47.2427 34.7236C47.2427 34.9632 47.4369 35.1575 47.6766 35.1575H48.9783C49.218 35.1575 49.4123 34.9632 49.4123 34.7236C49.4123 34.4839 49.218 34.2897 48.9783 34.2897Z" fill="#EEEEEE"/>
-<path d="M45.073 34.5067H44.2052C44.0854 34.5067 43.9883 34.6039 43.9883 34.7237C43.9883 34.8435 44.0854 34.9406 44.2052 34.9406H45.073C45.1929 34.9406 45.29 34.8435 45.29 34.7237C45.29 34.6039 45.1929 34.5067 45.073 34.5067Z" fill="#FDE5D8"/>
-<path d="M45.073 36.8932H44.2052C44.0854 36.8932 43.9883 36.9903 43.9883 37.1101C43.9883 37.23 44.0854 37.3271 44.2052 37.3271H45.073C45.1929 37.3271 45.29 37.23 45.29 37.1101C45.29 36.9903 45.1929 36.8932 45.073 36.8932Z" fill="#FDE5D8"/>
-<path d="M66.0312 11.2921H75.4472C76.6405 11.2921 77.6168 10.3158 77.6168 9.12254V2.83072C77.6168 1.63745 76.6405 0.661133 75.4472 0.661133H65.2502C64.0569 0.661133 63.0806 1.63745 63.0806 2.83072V11.943C63.0806 13.1363 63.7748 13.4617 64.6427 12.5939L66.0312 11.2921Z" fill="white" stroke="#FDE5D8" stroke-width="2"/>
-<path d="M72.0845 3.69861H66.4436C66.2639 3.69861 66.1182 3.84431 66.1182 4.02405C66.1182 4.20378 66.2639 4.34949 66.4436 4.34949H72.0845C72.2643 4.34949 72.41 4.20378 72.41 4.02405C72.41 3.84431 72.2643 3.69861 72.0845 3.69861Z" fill="#FDB692"/>
-<path d="M74.2541 5.65125H66.4436C66.2639 5.65125 66.1182 5.79695 66.1182 5.97668C66.1182 6.15642 66.2639 6.30212 66.4436 6.30212H74.2541C74.4339 6.30212 74.5796 6.15642 74.5796 5.97668C74.5796 5.79695 74.4339 5.65125 74.2541 5.65125Z" fill="#FDB692"/>
-<path d="M72.0845 7.60388H66.4436C66.2639 7.60388 66.1182 7.74959 66.1182 7.92932C66.1182 8.10906 66.2639 8.25476 66.4436 8.25476H72.0845C72.2643 8.25476 72.41 8.10906 72.41 7.92932C72.41 7.74959 72.2643 7.60388 72.0845 7.60388Z" fill="#FDB692"/>
-<path d="M64.1655 21.0553C65.9629 21.0553 67.4199 19.5982 67.4199 17.8009C67.4199 16.0035 65.9629 14.5465 64.1655 14.5465C62.3682 14.5465 60.9111 16.0035 60.9111 17.8009C60.9111 19.5982 62.3682 21.0553 64.1655 21.0553Z" fill="#FFF7F4" stroke="#FC6D26"/>
-<path d="M62.3867 15.1974C63.0376 16.1303 64.079 16.7161 65.2506 16.7161C65.9665 16.7161 66.6174 16.4991 67.2032 16.1303" stroke="#FC6D26" stroke-width="0.5"/>
-<path d="M62.9724 18.6687C63.1521 18.6687 63.2979 18.523 63.2979 18.3433C63.2979 18.1635 63.1521 18.0178 62.9724 18.0178C62.7927 18.0178 62.647 18.1635 62.647 18.3433C62.647 18.523 62.7927 18.6687 62.9724 18.6687Z" fill="#FC6D26"/>
-<path d="M65.3591 18.6687C65.5389 18.6687 65.6846 18.523 65.6846 18.3433C65.6846 18.1635 65.5389 18.0178 65.3591 18.0178C65.1794 18.0178 65.0337 18.1635 65.0337 18.3433C65.0337 18.523 65.1794 18.6687 65.3591 18.6687Z" fill="#FC6D26"/>
-<path d="M16.0005 39.7137C17.7978 39.7137 19.2549 38.2567 19.2549 36.4593C19.2549 34.662 17.7978 33.205 16.0005 33.205C14.2031 33.205 12.7461 34.662 12.7461 36.4593C12.7461 38.2567 14.2031 39.7137 16.0005 39.7137Z" fill="#F4F1FA" stroke="#6B4FBB"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1701 34.5068H18.7559C18.1701 33.5955 17.1721 32.988 16.0005 32.988C14.8289 32.988 13.8309 33.5955 13.2451 34.5068H13.8309L14.3733 33.8559L14.9157 34.5068L15.4581 33.8559L16.0005 34.5068L16.5429 33.8559L17.0853 34.5068L17.6277 33.8559L18.1701 34.5068Z" fill="#6B4FBB"/>
-<path d="M14.8074 37.1102C14.9871 37.1102 15.1328 36.9645 15.1328 36.7848C15.1328 36.6051 14.9871 36.4594 14.8074 36.4594C14.6276 36.4594 14.4819 36.6051 14.4819 36.7848C14.4819 36.9645 14.6276 37.1102 14.8074 37.1102Z" fill="#6B4FBB"/>
-<path d="M17.1936 37.1102C17.3733 37.1102 17.519 36.9645 17.519 36.7848C17.519 36.6051 17.3733 36.4594 17.1936 36.4594C17.0139 36.4594 16.8682 36.6051 16.8682 36.7848C16.8682 36.9645 17.0139 37.1102 17.1936 37.1102Z" fill="#6B4FBB"/>
-<path d="M13.0498 29.7336H3.63382C2.44055 29.7336 1.46423 28.7573 1.46423 27.5641V21.2722C1.46423 20.079 2.44055 19.1027 3.63382 19.1027H13.8309C15.0242 19.1027 16.0005 20.079 16.0005 21.2722V30.3845C16.0005 31.5778 15.3062 31.9032 14.4384 31.0354L13.0498 29.7336Z" fill="white" stroke="#E2DCF2" stroke-width="2"/>
-<path opacity="0.5" d="M4.82708 22.1401H10.468C10.6478 22.1401 10.7935 22.2858 10.7935 22.4656C10.7935 22.6453 10.6478 22.791 10.468 22.791H4.82708C4.64735 22.791 4.50164 22.6453 4.50164 22.4656C4.50164 22.2858 4.64735 22.1401 4.82708 22.1401Z" fill="#6B4FBB"/>
-<path opacity="0.5" d="M4.82724 24.0928H8.29859C8.47832 24.0928 8.62402 24.2385 8.62402 24.4182C8.62402 24.5979 8.47832 24.7437 8.29859 24.7437H4.82724C4.64751 24.7437 4.50181 24.5979 4.50181 24.4182C4.50181 24.2385 4.64751 24.0928 4.82724 24.0928Z" fill="#6B4FBB"/>
-<path opacity="0.5" d="M4.82724 26.0454H8.29859C8.47832 26.0454 8.62402 26.1911 8.62402 26.3708C8.62402 26.5506 8.47832 26.6963 8.29859 26.6963H4.82724C4.64751 26.6963 4.50181 26.5506 4.50181 26.3708C4.50181 26.1911 4.64751 26.0454 4.82724 26.0454Z" fill="#6B4FBB"/>
-</g>
-<defs>
-<clipPath id="clip0">
-<rect width="83.5292" height="48.8158" fill="white" transform="translate(-2.44092 0.661133)"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/app/assets/images/learn_gitlab/pipeline_created.svg b/app/assets/images/learn_gitlab/pipeline_created.svg
deleted file mode 100644
index 91c716be475..00000000000
--- a/app/assets/images/learn_gitlab/pipeline_created.svg
+++ /dev/null
@@ -1,38 +0,0 @@
-<svg width="52" height="48" viewBox="0 0 52 48" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M32.1404 11.789L45.6109 14.1173C47.0988 14.3744 48.0923 15.7653 47.8299 17.2237L44.9797 33.0687C44.7173 34.5272 43.2985 35.5011 41.8106 35.2439L28.34 32.9157C26.8521 32.6585 25.8586 31.2677 26.121 29.8092L28.9712 13.9642C29.2336 12.5057 30.6525 11.5318 32.1404 11.789Z" fill="white"/>
-<path d="M31.9504 12.8453C31.0576 12.691 30.2063 13.2754 30.0489 14.1505L27.1986 29.9955C27.0412 30.8705 27.6373 31.705 28.5301 31.8593L42.0006 34.1876C42.8933 34.3419 43.7446 33.7576 43.9021 32.8825L46.7523 17.0375C46.9097 16.1624 46.3136 15.3279 45.4209 15.1736L31.9504 12.8453ZM32.1404 11.789L45.6109 14.1173C47.0988 14.3744 48.0923 15.7653 47.8299 17.2237L44.9797 33.0687C44.7173 34.5272 43.2985 35.5011 41.8106 35.2439L28.34 32.9157C26.8521 32.6585 25.8586 31.2677 26.121 29.8092L28.9712 13.9642C29.2336 12.5057 30.6525 11.5318 32.1404 11.789Z" fill="#E1D8F9"/>
-<path d="M39.2265 9.7425L25.6003 8.57392C24.0951 8.44485 22.7683 9.53622 22.6366 11.0116L21.206 27.0398C21.0743 28.5151 22.1877 29.8158 23.6928 29.9449L37.3191 31.1134C38.8242 31.2425 40.1511 30.1511 40.2828 28.6758L41.7133 12.6476C41.845 11.1722 40.7316 9.87157 39.2265 9.7425Z" fill="black" fill-opacity="0.03"/>
-<path d="M34.477 47.7322H41.0349C46.4986 47.5866 50.8833 43.2004 50.8833 37.8104C50.8833 32.3306 46.3517 27.8886 40.7614 27.8886C40.4897 27.8886 40.2205 27.8993 39.9544 27.9197C38.6002 24.003 34.8187 21.1847 30.3659 21.1847C28.9201 21.1826 27.4908 21.4853 26.1748 22.0723C24.3556 19.4336 21.2753 17.6986 17.7818 17.6986C12.1915 17.6986 7.65986 22.1406 7.65986 27.6204C7.65986 27.8081 7.66533 27.995 7.67572 28.1803C3.26748 29.253 0 33.1573 0 37.8104C0 43.2902 4.53163 47.7322 10.122 47.7322C10.2106 47.7322 10.2987 47.7311 10.3868 47.729L34.477 47.7322Z" fill="#F4F0FF"/>
-<path d="M40.1486 9.42187L26.547 8.27441C25.0446 8.14767 23.718 9.24259 23.5839 10.72L22.1275 26.7704C21.9934 28.2478 23.1027 29.5482 24.6051 29.6749L38.2068 30.8224C39.7092 30.9491 41.0358 29.8542 41.1698 28.3768L42.6262 12.3264C42.7603 10.849 41.651 9.54862 40.1486 9.42187Z" fill="white"/>
-<path d="M26.4499 9.34444C25.5485 9.26839 24.7525 9.92534 24.6721 10.8118L23.2156 26.8622C23.1352 27.7486 23.8007 28.5288 24.7022 28.6049L38.3038 29.7523C39.2053 29.8284 40.0013 29.1714 40.0817 28.285L41.5381 12.2346C41.6185 11.3482 40.953 10.5679 40.0516 10.4919L26.4499 9.34444ZM26.547 8.27441L40.1486 9.42187C41.651 9.54862 42.7603 10.849 42.6262 12.3264L41.1698 28.3768C41.0358 29.8542 39.7092 30.9491 38.2068 30.8224L24.6051 29.6749C23.1027 29.5482 21.9934 28.2478 22.1275 26.7704L23.5839 10.72C23.718 9.24259 25.0446 8.14767 26.547 8.27441Z" fill="#E1D8F9"/>
-<path d="M28.8145 13.3182L27.7253 13.2263C27.4245 13.2009 27.1589 13.4202 27.1321 13.7161C27.1052 14.012 27.3273 14.2725 27.6281 14.2979L28.7173 14.3898C29.018 14.4152 29.2836 14.1959 29.3105 13.9C29.3373 13.6041 29.1152 13.3436 28.8145 13.3182Z" fill="#FC6D26"/>
-<path d="M33.2417 19.0816L32.1525 18.9897C31.8517 18.9643 31.5862 19.1836 31.5593 19.4795C31.5325 19.7755 31.7545 20.0359 32.0553 20.0613L33.1445 20.1533C33.4453 20.1787 33.7109 19.9593 33.7377 19.6634C33.7645 19.3675 33.5425 19.107 33.2417 19.0816Z" fill="#E1DBF1"/>
-<path d="M36.5464 22.0571L35.4572 21.9652C35.1564 21.9398 34.8908 22.1591 34.864 22.455C34.8372 22.7509 35.0592 23.0114 35.36 23.0368L36.4492 23.1287C36.75 23.1541 37.0155 22.9348 37.0424 22.6389C37.0692 22.343 36.8472 22.0825 36.5464 22.0571Z" fill="#FEE1D3"/>
-<path d="M36.5127 19.3624L35.4235 19.2705C35.1227 19.2451 34.8571 19.4644 34.8303 19.7603C34.8035 20.0562 35.0255 20.3167 35.3263 20.3421L36.4155 20.434C36.7163 20.4594 36.9819 20.2401 37.0087 19.9442C37.0355 19.6483 36.8135 19.3878 36.5127 19.3624Z" fill="#FEF0E8"/>
-<path d="M33.1744 13.6912L30.9954 13.5071C30.6946 13.4817 30.4289 13.7012 30.4021 13.9975C30.3752 14.2938 30.5974 14.5545 30.8982 14.58L33.0772 14.764C33.378 14.7895 33.6437 14.5699 33.6705 14.2736C33.6974 13.9774 33.4752 13.7166 33.1744 13.6912Z" fill="#EFEDF8"/>
-<path d="M27.8257 21.3098L27.2813 21.2638C26.9806 21.2385 26.7151 21.4576 26.6882 21.7534C26.6614 22.0491 26.8834 22.3094 27.1841 22.3348L27.7286 22.3807C28.0293 22.4061 28.2948 22.1869 28.3216 21.8912C28.3484 21.5955 28.1264 21.3351 27.8257 21.3098Z" fill="#FEF0E8"/>
-<path d="M30.5513 21.5437L30.0069 21.4977C29.7062 21.4723 29.4407 21.6915 29.4138 21.9873C29.387 22.283 29.609 22.5433 29.9097 22.5687L30.4541 22.6146C30.7548 22.64 31.0203 22.4208 31.0472 22.1251C31.074 21.8293 30.852 21.569 30.5513 21.5437Z" fill="#FC6D26"/>
-<path d="M33.2764 21.7772L32.732 21.7312C32.4313 21.7059 32.1657 21.925 32.1389 22.2208C32.1121 22.5165 32.3341 22.7768 32.6348 22.8022L33.1792 22.8481C33.4799 22.8735 33.7455 22.6543 33.7723 22.3586C33.7991 22.0629 33.5771 21.8025 33.2764 21.7772Z" fill="#E1D8F9"/>
-<path d="M29.6651 16.0818L27.4861 15.8977C27.1853 15.8723 26.9196 16.0919 26.8928 16.3881C26.866 16.6844 27.0881 16.9452 27.3889 16.9706L29.5679 17.1547C29.8688 17.1801 30.1344 16.9605 30.1613 16.6643C30.1881 16.368 29.966 16.1072 29.6651 16.0818Z" fill="#E1D8F9"/>
-<path d="M37.126 24.7972L34.9471 24.6132C34.6462 24.5877 34.3806 24.8073 34.3537 25.1036C34.3269 25.3998 34.549 25.6606 34.8499 25.686L37.0288 25.8701C37.3297 25.8955 37.5953 25.676 37.6222 25.3797C37.649 25.0834 37.4269 24.8227 37.126 24.7972Z" fill="#6B4FBB"/>
-<path d="M37.8406 16.7796L31.8467 16.2719C31.5457 16.2464 31.28 16.4666 31.2531 16.7638C31.2263 17.0609 31.4485 17.3225 31.7494 17.348L37.7434 17.8558C38.0443 17.8812 38.3101 17.661 38.3369 17.3638C38.3638 17.0667 38.1416 16.8051 37.8406 16.7796Z" fill="#C3B8E3"/>
-<path d="M33.0379 24.4431L27.0439 23.9353C26.743 23.9098 26.4773 24.1301 26.4504 24.4272C26.4235 24.7244 26.6457 24.986 26.9467 25.0114L32.9407 25.5192C33.2416 25.5447 33.5073 25.3245 33.5342 25.0273C33.5611 24.7301 33.3389 24.4686 33.0379 24.4431Z" fill="#FEE1D3"/>
-<path d="M38.0793 14.1112L35.3553 13.881C35.0545 13.8556 34.7888 14.0753 34.7619 14.3717C34.7351 14.6681 34.9572 14.929 35.2581 14.9544L37.982 15.1847C38.2829 15.2101 38.5486 14.9904 38.5754 14.694C38.6023 14.3976 38.3801 14.1367 38.0793 14.1112Z" fill="#E1DBF1"/>
-<path d="M29.9723 18.7993L27.2484 18.5691C26.9475 18.5437 26.6818 18.7634 26.655 19.0598C26.6282 19.3562 26.8503 19.6171 27.1512 19.6426L29.8751 19.8728C30.176 19.8982 30.4417 19.6785 30.4685 19.3821C30.4953 19.0857 30.2732 18.8248 29.9723 18.7993Z" fill="#6B4FBB"/>
-<path d="M35.2983 48H41.8562C47.3199 47.8544 51.7046 43.4682 51.7046 38.0782C51.7046 32.5984 47.173 28.1564 41.5827 28.1564C41.311 28.1564 41.0418 28.1671 40.7757 28.1875C39.4215 24.2708 35.64 21.4525 31.1871 21.4525C29.7414 21.4504 28.3121 21.7532 26.9961 22.3401C25.1769 19.7014 22.0965 17.9664 18.6031 17.9664C13.0128 17.9664 8.48115 22.4085 8.48115 27.8882C8.48115 28.076 8.48662 28.2629 8.49701 28.4482C4.08877 29.5208 0.821289 33.4252 0.821289 38.0782C0.821289 43.558 5.35292 48 10.9432 48C11.0319 48 11.12 47.999 11.2081 47.9968L35.2983 48Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M35.2976 47.4637H41.8407C47.0193 47.3258 51.1567 43.1667 51.1567 38.0782C51.1567 32.8947 46.87 28.6927 41.5819 28.6927C41.3259 28.6927 41.0712 28.7026 40.8179 28.7222L40.3936 28.7549L40.2568 28.3596C38.9505 24.5818 35.3216 21.9887 31.1864 21.9887C29.8189 21.9868 28.4669 22.2731 27.2222 22.8283L26.8009 23.0163L26.5418 22.6406C24.7718 20.0727 21.8233 18.5027 18.6024 18.5027C13.3143 18.5027 9.02753 22.7047 9.02753 27.8882C9.02753 28.0654 9.03246 28.2422 9.04258 28.4183L9.06775 28.8616L8.62813 28.9686C4.39332 29.9986 1.36768 33.7413 1.36768 38.0782C1.36768 43.2616 5.65446 47.4637 10.9425 47.4637C11.0262 47.4637 11.1099 47.4626 11.2073 47.4604L35.2976 47.4637Z" fill="white"/>
-<path d="M41.5827 28.1564C47.1729 28.1564 51.7046 32.5985 51.7046 38.0782C51.7046 43.4542 47.3344 47.854 41.8415 48H35.2983L11.2203 47.9967C11.1169 47.999 11.0296 48 10.9432 48C5.35303 48 0.821289 43.5579 0.821289 38.0782C0.821289 33.5134 3.99981 29.542 8.49703 28.4481C8.48641 28.2631 8.48115 28.0765 8.48115 27.8882C8.48115 22.4086 13.0129 17.9664 18.6031 17.9664C21.9832 17.9664 25.1114 19.6064 26.9958 22.3403C28.3124 21.7532 29.742 21.4504 31.1871 21.4525C35.535 21.4525 39.3871 24.1716 40.7757 28.1874C41.0441 28.1668 41.3131 28.1564 41.5827 28.1564ZM41.8266 46.9276C46.7122 46.7976 50.6104 42.873 50.6104 38.0782C50.6104 33.1909 46.5685 29.229 41.5827 29.229C41.3418 29.229 41.1015 29.2383 40.8615 29.2569L40.4372 29.2896C40.1876 29.3088 39.9566 29.1596 39.8762 28.9272L39.7393 28.5318C38.501 24.9504 35.0651 22.5251 31.1863 22.5251C29.8973 22.5232 28.6228 22.7932 27.4496 23.3165L27.0283 23.5045C26.7852 23.6129 26.4979 23.5336 26.3483 23.3167L26.0892 22.9409C24.4075 20.5012 21.6186 19.0391 18.6031 19.0391C13.6172 19.0391 9.57541 23.001 9.57541 27.8882C9.57541 28.0567 9.58012 28.2233 9.58961 28.3886L9.61478 28.8319C9.62938 29.089 9.45538 29.3201 9.20034 29.3822L8.76063 29.4892C4.75075 30.4645 1.91555 34.007 1.91555 38.0782C1.91555 42.9655 5.95738 46.9274 10.9432 46.9274C11.0209 46.9274 11.1001 46.9264 11.2081 46.9242L35.2984 46.9274L41.8266 46.9276Z" fill="#E1D8F9"/>
-<path d="M19.2501 27.9907C19.8019 27.7404 20.3915 27.5821 20.9945 27.5223L21.1208 27.0649C21.3387 26.2755 22.1656 25.8045 22.9613 26.0114L23.7546 26.2177C24.1383 26.3176 24.4646 26.564 24.6618 26.9026C24.8589 27.2412 24.9107 27.6443 24.8057 28.0231L24.6794 28.4806C25.1755 28.8277 25.6031 29.2528 25.9488 29.7327L26.4143 29.6081C27.2149 29.3942 28.0346 29.8574 28.2469 30.6437L28.4563 31.4252C28.5574 31.8033 28.5015 32.2068 28.3009 32.5468C28.1003 32.8869 27.7714 33.1356 27.3867 33.2383L26.9221 33.3629C26.892 33.6548 26.8373 33.9481 26.7564 34.241C26.6755 34.5339 26.5718 34.8144 26.4475 35.0819L26.7857 35.4149C27.3682 35.9895 27.3647 36.9241 26.7779 37.5044L26.1939 38.0796C25.9113 38.3577 25.5291 38.5148 25.1313 38.5162C24.7336 38.5176 24.3529 38.3633 24.0732 38.0871L23.7343 37.7533C23.1824 38.0036 22.5929 38.1619 21.9899 38.2217L21.8636 38.6791C21.6457 39.4685 20.8188 39.9395 20.0231 39.7326L19.2298 39.5263C18.8461 39.4264 18.5198 39.18 18.3226 38.8414C18.1255 38.5028 18.0737 38.0997 18.1787 37.7209L18.3049 37.2634C17.8136 36.919 17.3846 36.4958 17.0356 36.0113L16.5701 36.1359C15.7695 36.3498 14.9498 35.8866 14.7374 35.1003L14.5281 34.3188C14.427 33.9407 14.4829 33.5372 14.6835 33.1972C14.8841 32.8571 15.2129 32.6084 15.5977 32.5057L16.0623 32.3812C16.0929 32.0847 16.1484 31.791 16.228 31.503C16.3089 31.2101 16.4126 30.9296 16.5369 30.6622L16.1986 30.3291C15.6162 29.7545 15.6197 28.8199 16.2065 28.2397L16.7905 27.6645C17.073 27.3863 17.4553 27.2292 17.853 27.2278C18.2508 27.2264 18.6314 27.3807 18.9112 27.6569L19.2501 27.9907Z" fill="white"/>
-<path d="M20.9944 27.5222L21.121 27.0643C21.3393 26.2769 22.1613 25.806 22.9613 26.0114L23.7546 26.2177C24.5536 26.4255 25.0238 27.2335 24.8057 28.0232L24.6796 28.4802C25.1712 28.8248 25.6002 29.2481 25.9491 29.7327L26.4148 29.6079C27.2147 29.3945 28.0336 29.8569 28.247 30.6437L28.4564 31.4256C28.5575 31.8038 28.5014 32.2073 28.3006 32.5474C28.0999 32.8874 27.7708 33.136 27.3868 33.2383L26.9221 33.3628C26.8918 33.6578 26.8364 33.9512 26.7564 34.241C26.6764 34.5306 26.5733 34.8113 26.4475 35.0819L26.7861 35.4156C27.3676 35.9897 27.3645 36.9229 26.7779 37.5044L26.1937 38.0798C25.911 38.358 25.5287 38.515 25.1308 38.5163C24.733 38.5175 24.3523 38.363 24.0733 38.0872L23.7342 37.7533C23.1825 38.0036 22.593 38.1619 21.99 38.2218L21.8635 38.6797C21.6451 39.4672 20.8231 39.938 20.0231 39.7326L19.2298 39.5263C18.4308 39.3185 17.9606 38.5106 18.1787 37.7209L18.3049 37.2638C17.8132 36.9192 17.3842 36.496 17.0353 36.0114L16.5696 36.1361C15.7697 36.3495 14.9509 35.8871 14.7374 35.1003L14.528 34.3184C14.4269 33.9403 14.483 33.5367 14.6838 33.1967C14.8846 32.8566 15.2136 32.608 15.5977 32.5057L16.0623 32.3813C16.0926 32.0862 16.148 31.7928 16.2279 31.5033C16.3073 31.2153 16.4106 30.9341 16.537 30.6621L16.1983 30.3284C15.6168 29.7543 15.62 28.8211 16.2065 28.2396L16.7907 27.6643C17.0734 27.3861 17.4558 27.229 17.8536 27.2278C18.2515 27.2265 18.6321 27.3811 18.9111 27.6568L19.2501 27.9906C19.8125 27.7376 20.3968 27.5816 20.9944 27.5222ZM19.3541 29.1231C19.1465 29.2172 18.9024 29.1749 18.742 29.0169L18.1356 28.4197C18.0595 28.3445 17.956 28.3025 17.8477 28.3028C17.7395 28.3032 17.6354 28.3459 17.5587 28.4214L16.976 28.9953C16.8161 29.1539 16.8152 29.4089 16.9737 29.5654L17.5783 30.1611C17.7385 30.3189 17.7816 30.5589 17.6864 30.7633L17.5285 31.1027C17.4273 31.3204 17.3445 31.5458 17.2808 31.7768C17.2166 32.0096 17.1723 32.2446 17.148 32.4805L17.1099 32.8514C17.087 33.0746 16.926 33.2615 16.706 33.3205L15.8747 33.5432C15.77 33.5711 15.6805 33.6387 15.6258 33.7312C15.5712 33.8237 15.556 33.9335 15.5834 34.0361L15.7923 34.816C15.8505 35.0306 16.0743 35.1569 16.2923 35.0987L17.124 34.876C17.3443 34.817 17.5775 34.8987 17.7088 35.0811L17.927 35.3841C18.2062 35.7718 18.5493 36.1104 18.942 36.3857L19.2494 36.6007C19.4343 36.7301 19.5173 36.9598 19.4573 37.1768L19.2315 37.9947C19.1719 38.2106 19.2997 38.4302 19.5168 38.4867L20.3085 38.6926C20.5267 38.7486 20.7513 38.6199 20.8108 38.4054L21.0366 37.5874C21.0965 37.3706 21.2864 37.212 21.5131 37.1895L21.89 37.1522C22.3723 37.1043 22.8438 36.9776 23.2856 36.7771L23.6306 36.6208C23.8382 36.5268 24.082 36.5692 24.2424 36.7271L24.8488 37.3243C24.9249 37.3995 25.0285 37.4416 25.1367 37.4412C25.2449 37.4409 25.349 37.3981 25.4257 37.3226L26.0084 36.7487C26.1683 36.5901 26.1692 36.3351 26.0107 36.1786L25.4061 35.5829C25.2459 35.4251 25.2028 35.1851 25.298 34.9807L25.456 34.6412C25.5567 34.4246 25.6393 34.1998 25.7036 33.9672C25.7678 33.7345 25.8122 33.4995 25.8364 33.2636L25.8745 32.8927C25.8974 32.6695 26.0584 32.4825 26.2784 32.4236L27.1097 32.2009C27.2144 32.173 27.3039 32.1053 27.3586 32.0128C27.4132 31.9203 27.4284 31.8105 27.401 31.708L27.1921 30.928C27.1339 30.7135 26.9101 30.5871 26.6921 30.6453L25.8604 30.868C25.6401 30.9271 25.4069 30.8453 25.2756 30.663L25.0574 30.3599C24.7783 29.9722 24.4351 29.6336 24.0427 29.3586L23.7353 29.1438C23.5501 29.0145 23.4671 28.7847 23.527 28.5675L23.7529 27.7494C23.8125 27.5334 23.6847 27.3138 23.4676 27.2573L22.6759 27.0514C22.4578 26.9954 22.2332 27.1241 22.1736 27.3387L21.9478 28.1566C21.8879 28.3735 21.698 28.5321 21.4713 28.5545L21.0944 28.5918C20.6121 28.6397 20.1406 28.7665 19.6992 28.9668C19.696 28.9682 19.5809 29.0203 19.3541 29.1231Z" fill="#7B58CF"/>
-<path d="M19.4256 36.6322C20.4661 37.2185 21.8046 36.8571 22.4152 35.825C23.0258 34.7929 22.6774 33.4808 21.6369 32.8945C20.5964 32.3082 19.2579 32.6696 18.6473 33.7017C18.0367 34.7339 18.3851 36.0459 19.4256 36.6322Z" fill="white"/>
-<path d="M20.2669 34.8677C19.2265 34.2814 18.878 32.9693 19.4886 31.9372C20.0992 30.9051 21.4377 30.5437 22.4782 31.13C23.5187 31.7163 23.8671 33.0283 23.2565 34.0605C22.6459 35.0926 21.3074 35.454 20.2669 34.8677ZM20.8198 33.9333C21.34 34.2264 22.0092 34.0457 22.3145 33.5297C22.6199 33.0136 22.4456 32.3576 21.9254 32.0644C21.4051 31.7713 20.7359 31.952 20.4306 32.468C20.1253 32.9841 20.2995 33.6401 20.8198 33.9333Z" fill="#6B4FBB"/>
-<path d="M30.889 35.0709C31.3313 34.8678 31.8035 34.7389 32.2863 34.6895L32.3883 34.3201C32.5643 33.6826 33.2272 33.3008 33.8637 33.4663L34.4983 33.6313C34.8053 33.7113 35.066 33.9096 35.2232 34.1825C35.3803 34.4555 35.421 34.7807 35.3362 35.0867L35.2342 35.4561C35.6306 35.7354 35.972 36.0777 36.2478 36.4644L36.6207 36.3629C37.262 36.1888 37.9173 36.5612 38.0857 37.1955L38.2517 37.8259C38.3319 38.1309 38.2864 38.4566 38.1251 38.7315C37.9638 39.0063 37.7001 39.2077 37.3919 39.2913L37.0198 39.3927C36.9951 39.6284 36.9507 39.8652 36.8854 40.1018C36.82 40.3384 36.7365 40.565 36.6364 40.7811L36.9065 41.0493C37.3717 41.512 37.367 42.2664 36.8961 42.7358L36.4275 43.2012C36.2007 43.4262 35.8945 43.5537 35.5761 43.5556C35.2577 43.5575 34.9532 43.4336 34.7299 43.2112L34.4592 42.9424C34.017 43.1455 33.5447 43.2744 33.0619 43.3238L32.9599 43.6932C32.7839 44.3307 32.1211 44.7125 31.4845 44.547L30.8499 44.3819C30.543 44.302 30.2822 44.1037 30.1251 43.8308C29.9679 43.5578 29.9273 43.2326 30.0121 42.9266L30.1141 42.5572C29.7214 42.2801 29.3788 41.9393 29.1004 41.5488L28.7276 41.6503C28.0863 41.8245 27.431 41.4521 27.2626 40.8178L27.0965 40.1874C27.0163 39.8824 27.0619 39.5566 27.2231 39.2818C27.3844 39.007 27.6481 38.8056 27.9563 38.722L28.3285 38.6206C28.3536 38.3812 28.3986 38.144 28.4629 37.9115C28.5282 37.6749 28.6118 37.4483 28.7118 37.2322L28.4417 36.964C27.9766 36.5013 27.9813 35.7469 28.4522 35.2775L28.9207 34.8121C29.1475 34.5871 29.4538 34.4595 29.7722 34.4577C30.0906 34.4558 30.395 34.5797 30.6184 34.802L30.889 35.0709Z" fill="white"/>
-<path d="M32.1998 34.5947L32.2833 34.2923C32.4746 33.6024 33.1919 33.1869 33.8914 33.3665L34.526 33.5315C35.2246 33.7132 35.6326 34.4223 35.4416 35.1142L35.3583 35.4159C35.7153 35.6768 36.0302 35.9898 36.2925 36.3444L36.5904 36.2633C37.2909 36.0733 38.0069 36.4799 38.1913 37.1715L38.3575 37.8023C38.4444 38.133 38.3949 38.4857 38.2201 38.7834C38.045 39.0816 37.7581 39.3004 37.423 39.3911L37.1204 39.4735C37.0938 39.6938 37.0505 39.9128 36.9908 40.1294C36.931 40.3458 36.8557 40.5563 36.7651 40.7602L36.9851 40.9788C37.4903 41.4818 37.4856 42.298 36.9752 42.8081L36.5064 43.2737C36.2596 43.5186 35.9255 43.6575 35.5775 43.6595C35.2288 43.6614 34.8954 43.5256 34.6519 43.2828L34.4357 43.0682C34.026 43.2479 33.5922 43.3661 33.1486 43.4189L33.0651 43.7214C32.8738 44.4113 32.1565 44.8267 31.457 44.6472L30.8224 44.4821C30.1238 44.3005 29.7158 43.5913 29.9068 42.8994L29.9901 42.5978C29.6331 42.3368 29.3182 42.0238 29.0559 41.6693L28.758 41.7504C28.0575 41.9403 27.3415 41.5338 27.1571 40.8421L26.9909 40.2114C26.9041 39.8807 26.9535 39.5279 27.1283 39.2303C27.3034 38.9321 27.5903 38.7132 27.9254 38.6225L28.228 38.5401C28.2546 38.3199 28.2979 38.1009 28.3576 37.8845C28.417 37.6691 28.4924 37.4583 28.5833 37.2535L28.3633 37.0348C27.8581 36.5319 27.8628 35.7157 28.3732 35.2055L28.842 34.74C29.0888 34.4951 29.4229 34.3561 29.7709 34.3542C30.1196 34.3522 30.453 34.4881 30.6965 34.7308L30.9127 34.9455C31.3224 34.7658 31.7562 34.6475 32.1998 34.5947ZM31.0182 36.0782C30.8094 36.1741 30.5632 36.1315 30.4022 35.9716L29.9178 35.4906C29.878 35.4508 29.8241 35.4289 29.7676 35.4292C29.7105 35.4295 29.6548 35.4527 29.6133 35.4939L29.1459 35.958C29.057 36.0469 29.0562 36.1897 29.1419 36.275L29.6246 36.7547C29.7833 36.9124 29.8259 37.151 29.7315 37.3546L29.6044 37.6287C29.5253 37.7997 29.4605 37.9767 29.4105 38.1581C29.36 38.3408 29.3251 38.5252 29.3058 38.7103L29.2745 39.0097C29.2514 39.2318 29.0916 39.4178 28.8729 39.4774L28.2068 39.6588C28.1506 39.674 28.1018 39.7112 28.0716 39.7626C28.0411 39.8146 28.0325 39.8763 28.0474 39.9333L28.2131 40.5621C28.2441 40.6786 28.3616 40.7453 28.4764 40.7142L29.1425 40.5328C29.3644 40.4725 29.5996 40.555 29.731 40.7394L29.9051 40.9836C30.1208 41.2862 30.3862 41.5502 30.6899 41.7646L30.9355 41.9376C31.1195 42.0672 31.2018 42.2962 31.142 42.5126L30.9597 43.1732C30.9264 43.2936 30.995 43.4128 31.1094 43.4425L31.7424 43.6071C31.8578 43.6367 31.9794 43.5663 32.0125 43.447L32.1949 42.7864C32.2546 42.5701 32.4437 42.4117 32.6697 42.3887L32.9714 42.3578C33.3452 42.3195 33.711 42.2196 34.054 42.0621L34.3305 41.9353C34.5392 41.8396 34.7853 41.8823 34.9462 42.0421L35.4306 42.5231C35.4704 42.5628 35.5243 42.5848 35.5808 42.5845C35.6379 42.5841 35.6936 42.561 35.7352 42.5197L36.2025 42.0556C36.2914 41.9668 36.2922 41.8239 36.2065 41.7387L35.7238 41.259C35.5651 41.1013 35.5225 40.8626 35.6169 40.6591L35.7441 40.3847C35.8228 40.2147 35.8875 40.0382 35.9379 39.8556C35.9884 39.6728 36.0233 39.4884 36.0427 39.3034L36.0739 39.0039C36.097 38.7819 36.2568 38.5958 36.4755 38.5363L37.1416 38.3549C37.1978 38.3397 37.2466 38.3025 37.2768 38.2511C37.3073 38.1991 37.3159 38.1373 37.301 38.0803L37.1353 37.4515C37.1043 37.335 36.9868 37.2683 36.872 37.2995L36.2059 37.4808C35.9841 37.5412 35.7488 37.4586 35.6174 37.2743L35.4433 37.0301C35.2276 36.7274 34.9622 36.4634 34.6588 36.2493L34.4132 36.0765C34.2289 35.947 34.1465 35.7178 34.2063 35.5012L34.3888 34.8404C34.422 34.7201 34.3534 34.6009 34.239 34.5711L33.606 34.4065C33.4907 34.3769 33.369 34.4473 33.3359 34.5666L33.1535 35.2272C33.0938 35.4435 32.9047 35.6019 32.6788 35.625L32.377 35.6558C32.0032 35.6941 31.6374 35.794 31.2948 35.9514C31.2919 35.9528 31.1997 35.995 31.0182 36.0782Z" fill="#FC6D26"/>
-<path d="M31.0067 42.0443C31.839 42.5134 32.9136 42.2178 33.4068 41.3841C33.9 40.5504 33.6251 39.4944 32.7928 39.0253C31.9604 38.5563 30.8858 38.8519 30.3926 39.6855C29.8994 40.5192 30.1743 41.5753 31.0067 42.0443Z" fill="white"/>
-<path d="M31.6893 40.6162C30.8561 40.1466 30.5815 39.0919 31.073 38.2611C31.5645 37.4302 32.6378 37.135 33.471 37.6045C34.3042 38.074 34.5788 39.1288 34.0873 39.9596C33.5957 40.7905 32.5225 41.0857 31.6893 40.6162ZM32.1315 39.8686C32.5469 40.1027 33.0855 39.9546 33.3337 39.535C33.5819 39.1154 33.4441 38.5861 33.0288 38.3521C32.6134 38.118 32.0748 38.2661 31.8266 38.6857C31.5783 39.1053 31.7162 39.6346 32.1315 39.8686Z" fill="#FC6D26"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M16.8736 10.2573C16.9313 9.88553 16.9613 9.50478 16.9613 9.11717C16.9613 4.97039 13.5319 1.60876 9.30146 1.60876C5.07104 1.60876 1.6416 4.97039 1.6416 9.11717C1.6416 13.2639 5.07104 16.6256 9.30146 16.6256C11.7125 16.6256 13.8633 15.5337 15.2674 13.8269L17.582 13.5753C17.637 13.5693 17.6908 13.5552 17.7414 13.5334C18.0182 13.4145 18.1442 13.0982 18.0228 12.8269L16.8736 10.2573Z" fill="#F4F0FF"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M17.42 9.18492C17.4777 8.81314 17.5077 8.43239 17.5077 8.04478C17.5077 3.898 14.0783 0.536377 9.84784 0.536377C5.61742 0.536377 2.18799 3.898 2.18799 8.04478C2.18799 12.1916 5.61742 15.5532 9.84784 15.5532C12.2588 15.5532 14.4097 14.4613 15.8138 12.7545L18.1284 12.5029C18.1834 12.4969 18.2371 12.4828 18.2878 12.461C18.5646 12.3421 18.6905 12.0258 18.5692 11.7545L17.42 9.18492Z" fill="white"/>
-<path d="M19.0711 11.5391C19.3137 12.0817 19.0617 12.7143 18.5082 12.9522C18.4069 12.9957 18.2994 13.0239 18.1895 13.0359L16.0945 13.2636C14.5478 15.0412 12.2855 16.0894 9.84859 16.0894C5.31599 16.0894 1.6416 12.4877 1.6416 8.04471C1.6416 3.60174 5.31599 0 9.84859 0C14.3812 0 18.0556 3.60174 18.0556 8.04471C18.0556 8.40289 18.0317 8.75827 17.9843 9.10926L19.0711 11.5391ZM16.8798 9.10409C16.9339 8.75583 16.9613 8.40206 16.9613 8.04471C16.9613 4.19414 13.7768 1.07263 9.84859 1.07263C5.92034 1.07263 2.73587 4.19414 2.73587 8.04471C2.73587 11.8953 5.92034 15.0168 9.84859 15.0168C12.0288 15.0168 14.0459 14.0499 15.3886 12.4178L15.5301 12.2457L18.0689 11.9698L16.9196 9.4002L16.8559 9.2578L16.8798 9.10409Z" fill="#E1D8F9"/>
-<path d="M9.98471 11.9331C7.8695 11.9331 6.15479 10.2523 6.15479 8.17888C6.15479 6.1055 7.8695 4.42468 9.98471 4.42468C12.0999 4.42468 13.8146 6.1055 13.8146 8.17888C13.8146 10.2523 12.0999 11.9331 9.98471 11.9331ZM9.98471 11.1286C11.6467 11.1286 12.9939 9.80798 12.9939 8.17888C12.9939 6.54979 11.6467 5.22915 9.98471 5.22915C8.32276 5.22915 6.97548 6.54979 6.97548 8.17888C6.97548 9.80798 8.32276 11.1286 9.98471 11.1286Z" fill="#31AF64"/>
-<path d="M9.62376 8.52988L9.09523 8.0118C8.93843 7.85825 8.68438 7.85825 8.52758 8.0118C8.4521 8.08542 8.40967 8.1855 8.40967 8.28987C8.40967 8.39425 8.4521 8.49433 8.52758 8.56795L9.31408 9.33864C9.31836 9.34303 9.32274 9.34732 9.32721 9.35151C9.47767 9.499 9.71841 9.49873 9.86751 9.35285L11.4564 7.79539C11.5279 7.725 11.5679 7.62964 11.5677 7.53031C11.5674 7.43098 11.5269 7.33582 11.455 7.26578C11.3837 7.19514 11.2866 7.15528 11.1851 7.15503C11.0837 7.15478 10.9864 7.19415 10.9147 7.26444L9.62376 8.52988Z" fill="#31AF64"/>
-</svg>
diff --git a/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg b/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg
deleted file mode 100644
index 027767368a6..00000000000
--- a/app/assets/images/learn_gitlab/required_mr_approvals_enabled.svg
+++ /dev/null
@@ -1,70 +0,0 @@
-<svg width="80" height="56" viewBox="0 0 80 56" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M11.1567 40.6485C16.4079 40.6485 20.6649 36.3891 20.6649 31.135C20.6649 25.8808 16.4079 21.6215 11.1567 21.6215C5.9055 21.6215 1.64856 25.8808 1.64856 31.135C1.64856 36.3891 5.9055 40.6485 11.1567 40.6485Z" fill="#F9F9F9"/>
-<path d="M19.0163 30.4864C19.0163 35.1888 15.2065 38.9999 10.5081 38.9999C5.80976 38.9999 2 35.1888 2 30.4864C2 25.784 5.80976 21.9729 10.5081 21.9729C15.2065 21.9729 19.0163 25.784 19.0163 30.4864Z" fill="white" stroke="#EEEEEE" stroke-width="2"/>
-<path d="M10.5075 39.5674C15.52 39.5674 19.5834 35.5017 19.5834 30.4863C19.5834 25.471 15.52 21.4053 10.5075 21.4053C5.49496 21.4053 1.43152 25.471 1.43152 30.4863C1.43152 35.5017 5.49496 39.5674 10.5075 39.5674Z" stroke="#EEEEEE"/>
-<path d="M8.43196 33.0239C8.57802 32.9204 8.75259 32.8648 8.93158 32.8648H13.1009C13.817 32.8648 14.3975 32.284 14.3975 31.5675V28.5405C14.3975 27.824 13.817 27.2432 13.1009 27.2432H8.34686C7.63078 27.2432 7.05029 27.824 7.05029 28.5405V34.0031L8.43196 33.0239ZM8.93158 33.7296L7.20891 34.9505C7.09937 35.0282 6.96844 35.0699 6.8342 35.0699C6.47616 35.0699 6.18591 34.7795 6.18591 34.4212V28.5405C6.18591 27.3463 7.1534 26.3783 8.34686 26.3783H13.1009C14.2944 26.3783 15.2619 27.3463 15.2619 28.5405V31.5675C15.2619 32.7616 14.2944 33.7296 13.1009 33.7296H8.93158Z" fill="#FEE1D3"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M9.21191 29.6217C9.4506 29.6217 9.6441 29.8153 9.6441 30.0541C9.6441 30.293 9.4506 30.4866 9.21191 30.4866C8.97322 30.4866 8.77972 30.293 8.77972 30.0541C8.77972 29.8153 8.97322 29.6217 9.21191 29.6217ZM10.7246 29.6217C10.9633 29.6217 11.1568 29.8153 11.1568 30.0541C11.1568 30.293 10.9633 30.4866 10.7246 30.4866C10.4859 30.4866 10.2924 30.293 10.2924 30.0541C10.2924 29.8153 10.4859 29.6217 10.7246 29.6217ZM12.2372 29.6217C12.4759 29.6217 12.6694 29.8153 12.6694 30.0541C12.6694 30.293 12.4759 30.4866 12.2372 30.4866C11.9985 30.4866 11.805 30.293 11.805 30.0541C11.805 29.8153 11.9985 29.6217 12.2372 29.6217Z" fill="#FC6D26"/>
-<path d="M18.0716 14.0539C21.7713 14.0539 24.7705 11.0531 24.7705 7.35125C24.7705 3.64946 21.7713 0.64856 18.0716 0.64856C14.3719 0.64856 11.3727 3.64946 11.3727 7.35125C11.3727 11.0531 14.3719 14.0539 18.0716 14.0539Z" fill="#F9F9F9"/>
-<path d="M17.423 13.4054C21.1228 13.4054 24.122 10.4045 24.122 6.70269C24.122 3.0009 21.1228 0 17.423 0C13.7233 0 10.7241 3.0009 10.7241 6.70269C10.7241 10.4045 13.7233 13.4054 17.423 13.4054Z" fill="white"/>
-<path d="M19.1525 6.27026H15.6949C15.4563 6.27026 15.2628 6.46387 15.2628 6.7027C15.2628 6.94152 15.4563 7.13513 15.6949 7.13513H19.1525C19.3911 7.13513 19.5846 6.94152 19.5846 6.7027C19.5846 6.46387 19.3911 6.27026 19.1525 6.27026Z" fill="#6B4FBB"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M49.7467 48.0001H20.8808C19.4486 48.0001 18.2877 46.8384 18.2877 45.4055V12.5406C18.2877 11.1077 19.4486 9.94604 20.8808 9.94604H65.3962C66.8284 9.94604 67.9893 11.1077 67.9893 12.5406V16.5187C69.077 15.9128 70.3297 15.5677 71.6629 15.5677C75.84 15.5677 79.2262 18.9558 79.2262 23.1352C79.2262 27.3147 75.84 30.7028 71.6629 30.7028C70.3297 30.7028 69.077 30.3576 67.9893 29.7517V45.4055C67.9893 46.8384 66.8284 48.0001 65.3962 48.0001H62.8938C62.9761 48.4198 63.0192 48.8535 63.0192 49.2974C63.0192 52.9991 60.02 56 56.3202 56C52.6205 56 49.6213 52.9991 49.6213 49.2974C49.6213 48.8535 49.6644 48.4198 49.7467 48.0001Z" fill="#F9F9F9"/>
-<path d="M71.0139 30.0543C75.191 30.0543 78.5772 26.6662 78.5772 22.4868C78.5772 18.3073 75.191 14.9192 71.0139 14.9192C66.8368 14.9192 63.4506 18.3073 63.4506 22.4868C63.4506 26.6662 66.8368 30.0543 71.0139 30.0543Z" fill="white"/>
-<path d="M71.0148 29.6218C74.9532 29.6218 78.1459 26.4273 78.1459 22.4867C78.1459 18.5461 74.9532 15.3516 71.0148 15.3516C67.0764 15.3516 63.8837 18.5461 63.8837 22.4867C63.8837 26.4273 67.0764 29.6218 71.0148 29.6218Z" stroke="#EEEEEE" stroke-width="2"/>
-<path d="M71.014 25.7303C72.8042 25.7303 74.2554 24.2782 74.2554 22.487C74.2554 20.6958 72.8042 19.2438 71.014 19.2438C69.2238 19.2438 67.7726 20.6958 67.7726 22.487C67.7726 24.2782 69.2238 25.7303 71.014 25.7303Z" fill="#F4F1FA" stroke="#6B4FBB"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M71.9682 20.5401L71.9667 20.5401L71.4366 19.8925L70.9063 20.5404L70.9051 20.5404L70.3748 19.8925L69.8443 20.5407L69.8436 20.5407L69.3131 19.8925L68.7823 20.541L68.2041 20.5411C68.7671 19.6321 69.7666 19.0276 70.9057 19.0276C72.0443 19.0276 73.0433 19.6315 73.6065 20.5397L73.0282 20.5399L72.4983 19.8925L71.9682 20.5401Z" fill="#6B4FBB"/>
-<path d="M69.8258 23.1357C70.0048 23.1357 70.1499 22.9905 70.1499 22.8114C70.1499 22.6323 70.0048 22.4871 69.8258 22.4871C69.6468 22.4871 69.5016 22.6323 69.5016 22.8114C69.5016 22.9905 69.6468 23.1357 69.8258 23.1357Z" fill="#6B4FBB"/>
-<path d="M72.2021 23.1357C72.3811 23.1357 72.5263 22.9905 72.5263 22.8114C72.5263 22.6323 72.3811 22.4871 72.2021 22.4871C72.0231 22.4871 71.878 22.6323 71.878 22.8114C71.878 22.9905 72.0231 23.1357 72.2021 23.1357Z" fill="#6B4FBB"/>
-<path d="M65.1797 9.29712H19.7999C18.6064 9.29712 17.6389 10.2652 17.6389 11.4593V45.189C17.6389 46.3831 18.6064 47.3511 19.7999 47.3511H65.1797C66.3731 47.3511 67.3406 46.3831 67.3406 45.189V11.4593C67.3406 10.2652 66.3731 9.29712 65.1797 9.29712Z" fill="white"/>
-<path d="M64.7466 9.72986H20.2311C19.0377 9.72986 18.0702 10.6979 18.0702 11.892V44.7568C18.0702 45.951 19.0377 46.919 20.2311 46.919H64.7466C65.94 46.919 66.9075 45.951 66.9075 44.7568V11.892C66.9075 10.6979 65.94 9.72986 64.7466 9.72986Z" stroke="#EEEEEE" stroke-width="2"/>
-<path d="M25.6338 18.8108C25.6338 18.572 25.4403 18.3784 25.2016 18.3784C24.9629 18.3784 24.7694 18.572 24.7694 18.8108V44.3243C24.7694 44.5632 24.9629 44.7568 25.2016 44.7568C25.4403 44.7568 25.6338 44.5632 25.6338 44.3243V18.8108Z" fill="#EEEEEE"/>
-<path d="M22.177 22.0541H20.8804C20.6417 22.0541 20.4482 22.2477 20.4482 22.4865C20.4482 22.7253 20.6417 22.9189 20.8804 22.9189H22.177C22.4157 22.9189 22.6092 22.7253 22.6092 22.4865C22.6092 22.2477 22.4157 22.0541 22.177 22.0541Z" fill="#FEE1D3"/>
-<path d="M22.177 24.6486H20.8804C20.6417 24.6486 20.4482 24.8422 20.4482 25.081C20.4482 25.3198 20.6417 25.5134 20.8804 25.5134H22.177C22.4157 25.5134 22.6092 25.3198 22.6092 25.081C22.6092 24.8422 22.4157 24.6486 22.177 24.6486Z" fill="#F0EDF8"/>
-<path d="M22.177 27.2432H20.8804C20.6417 27.2432 20.4482 27.4368 20.4482 27.6756C20.4482 27.9144 20.6417 28.108 20.8804 28.108H22.177C22.4157 28.108 22.6092 27.9144 22.6092 27.6756C22.6092 27.4368 22.4157 27.2432 22.177 27.2432Z" fill="#FEF0E9"/>
-<path d="M22.177 29.8379H20.8804C20.6417 29.8379 20.4482 30.0315 20.4482 30.2703C20.4482 30.5091 20.6417 30.7028 20.8804 30.7028H22.177C22.4157 30.7028 22.6092 30.5091 22.6092 30.2703C22.6092 30.0315 22.4157 29.8379 22.177 29.8379Z" fill="#FEE1D3"/>
-<path d="M22.177 32.4324H20.8804C20.6417 32.4324 20.4482 32.626 20.4482 32.8648C20.4482 33.1036 20.6417 33.2972 20.8804 33.2972H22.177C22.4157 33.2972 22.6092 33.1036 22.6092 32.8648C22.6092 32.626 22.4157 32.4324 22.177 32.4324Z" fill="#E1DBF1"/>
-<path d="M22.177 35.027H20.8804C20.6417 35.027 20.4482 35.2206 20.4482 35.4594C20.4482 35.6982 20.6417 35.8918 20.8804 35.8918H22.177C22.4157 35.8918 22.6092 35.6982 22.6092 35.4594C22.6092 35.2206 22.4157 35.027 22.177 35.027Z" fill="#F0EDF8"/>
-<path d="M22.177 37.6216H20.8804C20.6417 37.6216 20.4482 37.8152 20.4482 38.054C20.4482 38.2928 20.6417 38.4864 20.8804 38.4864H22.177C22.4157 38.4864 22.6092 38.2928 22.6092 38.054C22.6092 37.8152 22.4157 37.6216 22.177 37.6216Z" fill="#FEF0E9"/>
-<path d="M22.177 40.2161H20.8804C20.6417 40.2161 20.4482 40.4097 20.4482 40.6485C20.4482 40.8873 20.6417 41.0809 20.8804 41.0809H22.177C22.4157 41.0809 22.6092 40.8873 22.6092 40.6485C22.6092 40.4097 22.4157 40.2161 22.177 40.2161Z" fill="#FEE1D3"/>
-<path d="M32.1164 22.0538H29.9555C29.7168 22.0538 29.5233 22.2474 29.5233 22.4863C29.5233 22.7251 29.7168 22.9187 29.9555 22.9187H32.1164C32.3551 22.9187 32.5486 22.7251 32.5486 22.4863C32.5486 22.2474 32.3551 22.0538 32.1164 22.0538Z" fill="#6B4FBB"/>
-<path d="M36.439 22.0537H34.278C34.0393 22.0537 33.8458 22.2473 33.8458 22.4861C33.8458 22.725 34.0393 22.9186 34.278 22.9186H36.439C36.6776 22.9186 36.8711 22.725 36.8711 22.4861C36.8711 22.2473 36.6776 22.0537 36.439 22.0537Z" fill="#F0EDF8"/>
-<path d="M40.7601 22.0537H38.5992C38.3605 22.0537 38.167 22.2473 38.167 22.4861C38.167 22.725 38.3605 22.9186 38.5992 22.9186H40.7601C40.9988 22.9186 41.1923 22.725 41.1923 22.4861C41.1923 22.2473 40.9988 22.0537 40.7601 22.0537Z" fill="#FEF0E9"/>
-<path d="M32.1161 24.6484H29.9551C29.7164 24.6484 29.5229 24.842 29.5229 25.0809C29.5229 25.3197 29.7164 25.5133 29.9551 25.5133H32.1161C32.3548 25.5133 32.5483 25.3197 32.5483 25.0809C32.5483 24.842 32.3548 24.6484 32.1161 24.6484Z" fill="#F0EDF8"/>
-<path d="M40.7601 27.243H38.5992C38.3605 27.243 38.167 27.4366 38.167 27.6755C38.167 27.9143 38.3605 28.1079 38.5992 28.1079H40.7601C40.9988 28.1079 41.1923 27.9143 41.1923 27.6755C41.1923 27.4366 40.9988 27.243 40.7601 27.243Z" fill="#FEF0E9"/>
-<path d="M32.1161 32.4323H29.9551C29.7164 32.4323 29.5229 32.6259 29.5229 32.8647C29.5229 33.1035 29.7164 33.2971 29.9551 33.2971H32.1161C32.3548 33.2971 32.5483 33.1035 32.5483 32.8647C32.5483 32.6259 32.3548 32.4323 32.1161 32.4323Z" fill="#E1DBF1"/>
-<path d="M40.7601 29.8378H38.5992C38.3605 29.8378 38.167 30.0314 38.167 30.2702C38.167 30.509 38.3605 30.7026 38.5992 30.7026H40.7601C40.9988 30.7026 41.1923 30.509 41.1923 30.2702C41.1923 30.0314 40.9988 29.8378 40.7601 29.8378Z" fill="#FEF0E9"/>
-<path d="M34.9263 24.6484H34.278C34.0393 24.6484 33.8458 24.842 33.8458 25.0809C33.8458 25.3197 34.0393 25.5133 34.278 25.5133H34.9263C35.165 25.5133 35.3585 25.3197 35.3585 25.0809C35.3585 24.842 35.165 24.6484 34.9263 24.6484Z" fill="#FEE1D3"/>
-<path d="M36.4379 29.8378H35.7896C35.5509 29.8378 35.3574 30.0314 35.3574 30.2702C35.3574 30.509 35.5509 30.7026 35.7896 30.7026H36.4379C36.6766 30.7026 36.8701 30.509 36.8701 30.2702C36.8701 30.0314 36.6766 29.8378 36.4379 29.8378Z" fill="#6B4FBB"/>
-<path d="M34.9263 32.4323H34.278C34.0393 32.4323 33.8458 32.6259 33.8458 32.8647C33.8458 33.1035 34.0393 33.2971 34.278 33.2971H34.9263C35.165 33.2971 35.3585 33.1035 35.3585 32.8647C35.3585 32.6259 35.165 32.4323 34.9263 32.4323Z" fill="#FEE1D3"/>
-<path d="M30.6034 27.243H29.9551C29.7164 27.243 29.5229 27.4366 29.5229 27.6755C29.5229 27.9143 29.7164 28.1079 29.9551 28.1079H30.6034C30.8421 28.1079 31.0356 27.9143 31.0356 27.6755C31.0356 27.4366 30.8421 27.243 30.6034 27.243Z" fill="#FC6D26"/>
-<path d="M36.4383 27.243H32.7647C32.526 27.243 32.3325 27.4366 32.3325 27.6755C32.3325 27.9143 32.526 28.1079 32.7647 28.1079H36.4383C36.677 28.1079 36.8705 27.9143 36.8705 27.6755C36.8705 27.4366 36.677 27.243 36.4383 27.243Z" fill="#E1DBF1"/>
-<path d="M33.6287 29.8378H29.9551C29.7164 29.8378 29.5229 30.0314 29.5229 30.2702C29.5229 30.509 29.7164 30.7026 29.9551 30.7026H33.6287C33.8674 30.7026 34.0609 30.509 34.0609 30.2702C34.0609 30.0314 33.8674 29.8378 33.6287 29.8378Z" fill="#EEEEEE"/>
-<path d="M37.7342 24.6484H37.0859C36.8472 24.6484 36.6537 24.842 36.6537 25.0809C36.6537 25.3197 36.8472 25.5133 37.0859 25.5133H37.7342C37.9728 25.5133 38.1663 25.3197 38.1663 25.0809C38.1663 24.842 37.9728 24.6484 37.7342 24.6484Z" fill="#6B4FBB"/>
-<path d="M53.2933 22.054H51.1323C50.8936 22.054 50.7001 22.2476 50.7001 22.4864C50.7001 22.7252 50.8936 22.9188 51.1323 22.9188H53.2933C53.532 22.9188 53.7254 22.7252 53.7254 22.4864C53.7254 22.2476 53.532 22.054 53.2933 22.054Z" fill="#FEE1D3"/>
-<path d="M57.6143 22.054H55.4533C55.2146 22.054 55.0211 22.2476 55.0211 22.4864C55.0211 22.7252 55.2146 22.9188 55.4533 22.9188H57.6143C57.8529 22.9188 58.0464 22.7252 58.0464 22.4864C58.0464 22.2476 57.8529 22.054 57.6143 22.054Z" fill="#F0EDF8"/>
-<path d="M61.9367 22.054H59.7758C59.5371 22.054 59.3436 22.2476 59.3436 22.4864C59.3436 22.7252 59.5371 22.9188 59.7758 22.9188H61.9367C62.1754 22.9188 62.3689 22.7252 62.3689 22.4864C62.3689 22.2476 62.1754 22.054 61.9367 22.054Z" fill="#FC6D26"/>
-<path d="M53.2931 24.6486H51.1321C50.8934 24.6486 50.7 24.8422 50.7 25.081C50.7 25.3198 50.8934 25.5134 51.1321 25.5134H53.2931C53.5318 25.5134 53.7253 25.3198 53.7253 25.081C53.7253 24.8422 53.5318 24.6486 53.2931 24.6486Z" fill="#FEF0E9"/>
-<path d="M61.9367 27.2432H59.7758C59.5371 27.2432 59.3436 27.4368 59.3436 27.6756C59.3436 27.9144 59.5371 28.108 59.7758 28.108H61.9367C62.1754 28.108 62.3689 27.9144 62.3689 27.6756C62.3689 27.4368 62.1754 27.2432 61.9367 27.2432Z" fill="#E1DBF1"/>
-<path d="M53.2931 32.4324H51.1321C50.8934 32.4324 50.7 32.626 50.7 32.8648C50.7 33.1036 50.8934 33.2972 51.1321 33.2972H53.2931C53.5318 33.2972 53.7253 33.1036 53.7253 32.8648C53.7253 32.626 53.5318 32.4324 53.2931 32.4324Z" fill="#F0EDF8"/>
-<path d="M61.9367 29.8378H59.7758C59.5371 29.8378 59.3436 30.0314 59.3436 30.2702C59.3436 30.509 59.5371 30.7026 59.7758 30.7026H61.9367C62.1754 30.7026 62.3689 30.509 62.3689 30.2702C62.3689 30.0314 62.1754 29.8378 61.9367 29.8378Z" fill="#FEE1D3"/>
-<path d="M56.1016 24.6486H55.4533C55.2146 24.6486 55.0211 24.8422 55.0211 25.081C55.0211 25.3198 55.2146 25.5134 55.4533 25.5134H56.1016C56.3403 25.5134 56.5338 25.3198 56.5338 25.081C56.5338 24.8422 56.3403 24.6486 56.1016 24.6486Z" fill="#FC6D26"/>
-<path d="M57.6149 29.8378H56.9666C56.7279 29.8378 56.5344 30.0314 56.5344 30.2702C56.5344 30.509 56.7279 30.7026 56.9666 30.7026H57.6149C57.8536 30.7026 58.0471 30.509 58.0471 30.2702C58.0471 30.0314 57.8536 29.8378 57.6149 29.8378Z" fill="#6B4FBB"/>
-<path d="M56.1016 32.4324H55.4533C55.2146 32.4324 55.0211 32.626 55.0211 32.8648C55.0211 33.1036 55.2146 33.2972 55.4533 33.2972H56.1016C56.3403 33.2972 56.5338 33.1036 56.5338 32.8648C56.5338 32.626 56.3403 32.4324 56.1016 32.4324Z" fill="#FC6D26"/>
-<path d="M51.7804 27.2432H51.1321C50.8934 27.2432 50.7 27.4368 50.7 27.6756C50.7 27.9144 50.8934 28.108 51.1321 28.108H51.7804C52.0191 28.108 52.2126 27.9144 52.2126 27.6756C52.2126 27.4368 52.0191 27.2432 51.7804 27.2432Z" fill="#6B4FBB"/>
-<path d="M57.6153 27.2432H53.9417C53.703 27.2432 53.5095 27.4368 53.5095 27.6756C53.5095 27.9144 53.703 28.108 53.9417 28.108H57.6153C57.854 28.108 58.0475 27.9144 58.0475 27.6756C58.0475 27.4368 57.854 27.2432 57.6153 27.2432Z" fill="#FEE1D3"/>
-<path d="M54.8057 29.8378H51.1321C50.8934 29.8378 50.7 30.0314 50.7 30.2702C50.7 30.509 50.8934 30.7026 51.1321 30.7026H54.8057C55.0444 30.7026 55.2379 30.509 55.2379 30.2702C55.2379 30.0314 55.0444 29.8378 54.8057 29.8378Z" fill="#FEF0E9"/>
-<path d="M58.9112 24.6486H58.2629C58.0242 24.6486 57.8307 24.8422 57.8307 25.081C57.8307 25.3198 58.0242 25.5134 58.2629 25.5134H58.9112C59.1499 25.5134 59.3433 25.3198 59.3433 25.081C59.3433 24.8422 59.1499 24.6486 58.9112 24.6486Z" fill="#6B4FBB"/>
-<path d="M32.1163 35.027H29.9553C29.7166 35.027 29.5231 35.2206 29.5231 35.4594C29.5231 35.6982 29.7166 35.8918 29.9553 35.8918H32.1163C32.355 35.8918 32.5484 35.6982 32.5484 35.4594C32.5484 35.2206 32.355 35.027 32.1163 35.027Z" fill="#F0EDF8"/>
-<path d="M36.4372 35.027H34.2763C34.0376 35.027 33.8441 35.2206 33.8441 35.4594C33.8441 35.6982 34.0376 35.8918 34.2763 35.8918H36.4372C36.6759 35.8918 36.8694 35.6982 36.8694 35.4594C36.8694 35.2206 36.6759 35.027 36.4372 35.027Z" fill="#6B4FBB"/>
-<path d="M40.7597 35.027H38.5988C38.3601 35.027 38.1666 35.2206 38.1666 35.4594C38.1666 35.6982 38.3601 35.8918 38.5988 35.8918H40.7597C40.9984 35.8918 41.1919 35.6982 41.1919 35.4594C41.1919 35.2206 40.9984 35.027 40.7597 35.027Z" fill="#E1DBF1"/>
-<path d="M32.1161 37.6216H29.9551C29.7164 37.6216 29.5229 37.8152 29.5229 38.054C29.5229 38.2928 29.7164 38.4864 29.9551 38.4864H32.1161C32.3548 38.4864 32.5483 38.2928 32.5483 38.054C32.5483 37.8152 32.3548 37.6216 32.1161 37.6216Z" fill="#FEF0E9"/>
-<path d="M40.7597 40.2161H38.5988C38.3601 40.2161 38.1666 40.4097 38.1666 40.6485C38.1666 40.8873 38.3601 41.0809 38.5988 41.0809H40.7597C40.9984 41.0809 41.1919 40.8873 41.1919 40.6485C41.1919 40.4097 40.9984 40.2161 40.7597 40.2161Z" fill="#FEE1D3"/>
-<path d="M34.9246 37.6216H34.2763C34.0376 37.6216 33.8441 37.8152 33.8441 38.054C33.8441 38.2928 34.0376 38.4864 34.2763 38.4864H34.9246C35.1633 38.4864 35.3568 38.2928 35.3568 38.054C35.3568 37.8152 35.1633 37.6216 34.9246 37.6216Z" fill="#EEEEEE"/>
-<path d="M30.6034 40.2161H29.9551C29.7164 40.2161 29.5229 40.4097 29.5229 40.6485C29.5229 40.8873 29.7164 41.0809 29.9551 41.0809H30.6034C30.8421 41.0809 31.0356 40.8873 31.0356 40.6485C31.0356 40.4097 30.8421 40.2161 30.6034 40.2161Z" fill="#6B4FBB"/>
-<path d="M36.4383 40.2161H32.7647C32.526 40.2161 32.3325 40.4097 32.3325 40.6485C32.3325 40.8873 32.526 41.0809 32.7647 41.0809H36.4383C36.677 41.0809 36.8705 40.8873 36.8705 40.6485C36.8705 40.4097 36.677 40.2161 36.4383 40.2161Z" fill="#FEF0E9"/>
-<path d="M37.7342 37.6216H37.0859C36.8472 37.6216 36.6537 37.8152 36.6537 38.054C36.6537 38.2928 36.8472 38.4864 37.0859 38.4864H37.7342C37.9728 38.4864 38.1663 38.2928 38.1663 38.054C38.1663 37.8152 37.9728 37.6216 37.7342 37.6216Z" fill="#FC6D26"/>
-<path d="M46.3791 25.2972C46.3791 25.0584 46.1856 24.8647 45.947 24.8647C45.7083 24.8647 45.5148 25.0584 45.5148 25.2972V38.0539C45.5148 38.2927 45.7083 38.4863 45.947 38.4863C46.1856 38.4863 46.3791 38.2927 46.3791 38.0539V25.2972Z" fill="#EEEEEE"/>
-<path d="M66.9082 15.135H18.0709C17.8322 15.135 17.6387 15.3286 17.6387 15.5674C17.6387 15.8063 17.8322 15.9999 18.0709 15.9999H66.9082C67.1469 15.9999 67.3404 15.8063 67.3404 15.5674C67.3404 15.3286 67.1469 15.135 66.9082 15.135Z" fill="#EEEEEE"/>
-<path d="M55.8884 55.3513C59.5881 55.3513 62.5874 52.3504 62.5874 48.6486C62.5874 44.9468 59.5881 41.9459 55.8884 41.9459C52.1887 41.9459 49.1895 44.9468 49.1895 48.6486C49.1895 52.3504 52.1887 55.3513 55.8884 55.3513Z" fill="white"/>
-<path d="M55.8878 54.9188C59.3489 54.9188 62.1546 52.1115 62.1546 48.6486C62.1546 45.1856 59.3489 42.3783 55.8878 42.3783C52.4268 42.3783 49.6211 45.1856 49.6211 48.6486C49.6211 52.1115 52.4268 54.9188 55.8878 54.9188Z" stroke="#EEEEEE" stroke-width="2"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M55.457 48.2161V46.9188C55.457 46.6799 55.6505 46.4863 55.8892 46.4863C56.1278 46.4863 56.3213 46.6799 56.3213 46.9188V48.2161H57.6179C57.8566 48.2161 58.0501 48.4097 58.0501 48.6485C58.0501 48.8873 57.8566 49.0809 57.6179 49.0809H56.3213V50.3782C56.3213 50.617 56.1278 50.8106 55.8892 50.8106C55.6505 50.8106 55.457 50.617 55.457 50.3782V49.0809H54.1604C53.9217 49.0809 53.7282 48.8873 53.7282 48.6485C53.7282 48.4097 53.9217 48.2161 54.1604 48.2161H55.457Z" fill="#FC6D26"/>
-</svg>
diff --git a/app/assets/images/learn_gitlab/security_scan_enabled.svg b/app/assets/images/learn_gitlab/security_scan_enabled.svg
deleted file mode 100644
index eea0693484c..00000000000
--- a/app/assets/images/learn_gitlab/security_scan_enabled.svg
+++ /dev/null
@@ -1,36 +0,0 @@
-<svg width="47" height="47" viewBox="0 0 47 47" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M23.0588 47C35.7939 47 46.1176 36.6762 46.1176 23.9411C46.1176 11.2061 35.7939 0.882324 23.0588 0.882324C10.3238 0.882324 0 11.2061 0 23.9411C0 36.6762 10.3238 47 23.0588 47Z" fill="#EEEEEE"/>
-<path d="M23.0588 45.1176C34.7542 45.1176 44.2353 35.6366 44.2353 23.9411C44.2353 12.2457 34.7542 2.76465 23.0588 2.76465C11.3634 2.76465 1.88232 12.2457 1.88232 23.9411C1.88232 35.6366 11.3634 45.1176 23.0588 45.1176Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M16.3292 14.5295H16.9802C17.4786 14.5295 17.8827 14.9336 17.8827 15.432V16.8825C17.8827 17.4023 17.4613 17.8237 16.9415 17.8237H13.6556C13.1683 17.8237 12.7732 17.4286 12.7732 16.9413C12.7732 16.6257 12.9419 16.334 13.2155 16.1766L15.8598 14.655C16.0026 14.5728 16.1644 14.5295 16.3292 14.5295ZM12.2356 23.0001H16.9415C17.4613 23.0001 17.8827 23.4215 17.8827 23.9413V25.3531C17.8827 25.8729 17.4613 26.2942 16.9415 26.2942H12.2356C11.7158 26.2942 11.2944 25.8729 11.2944 25.3531V23.9413C11.2944 23.4215 11.7158 23.0001 12.2356 23.0001Z" fill="#EFEDF8"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M6.11793 18.7649H10.8238C11.3436 18.7649 11.765 19.1863 11.765 19.7061V21.1178C11.765 21.6376 11.3436 22.059 10.8238 22.059H6.11793C5.59814 22.059 5.17676 21.6376 5.17676 21.1178V19.7061C5.17676 19.1863 5.59814 18.7649 6.11793 18.7649Z" fill="#F9E2D5"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M40.4709 18.7649H35.765C35.2452 18.7649 34.8239 19.1863 34.8239 19.7061V21.1178C34.8239 21.6376 35.2452 22.059 35.765 22.059H40.4709C40.9907 22.059 41.4121 21.6376 41.4121 21.1178V19.7061C41.4121 19.1863 40.9907 18.7649 40.4709 18.7649Z" fill="#F9E2D5"/>
-<path d="M16.9415 31.0002H12.2356C11.7158 31.0002 11.2944 31.4216 11.2944 31.9414V33.3532C11.2944 33.873 11.7158 34.2944 12.2356 34.2944H16.9415C17.4613 34.2944 17.8827 33.873 17.8827 33.3532V31.9414C17.8827 31.4216 17.4613 31.0002 16.9415 31.0002Z" fill="#F9E2D5"/>
-<path d="M29.6474 31.0002H34.3533C34.8731 31.0002 35.2944 31.4216 35.2944 31.9414V33.3532C35.2944 33.873 34.8731 34.2944 34.3533 34.2944H29.6474C29.1276 34.2944 28.7062 33.873 28.7062 33.3532V31.9414C28.7062 31.4216 29.1276 31.0002 29.6474 31.0002Z" fill="#F9E2D5"/>
-<path d="M9.41202 31.0002H8.00026C7.48046 31.0002 7.05908 31.4216 7.05908 31.9414V33.3532C7.05908 33.873 7.48046 34.2944 8.00026 34.2944H9.41202C9.93182 34.2944 10.3532 33.873 10.3532 33.3532V31.9414C10.3532 31.4216 9.93182 31.0002 9.41202 31.0002Z" fill="#F9E2D5"/>
-<path d="M37.1768 31.0002H38.5886C39.1084 31.0002 39.5298 31.4216 39.5298 31.9414V33.3532C39.5298 33.873 39.1084 34.2944 38.5886 34.2944H37.1768C36.657 34.2944 36.2357 33.873 36.2357 33.3532V31.9414C36.2357 31.4216 36.657 31.0002 37.1768 31.0002Z" fill="#F9E2D5"/>
-<path d="M9.41202 23.0002H8.00026C7.48046 23.0002 7.05908 23.4216 7.05908 23.9414V25.3532C7.05908 25.873 7.48046 26.2944 8.00026 26.2944H9.41202C9.93182 26.2944 10.3532 25.873 10.3532 25.3532V23.9414C10.3532 23.4216 9.93182 23.0002 9.41202 23.0002Z" fill="#F9E2D5"/>
-<path d="M37.1768 23.0002H38.5886C39.1084 23.0002 39.5298 23.4216 39.5298 23.9414V25.3532C39.5298 25.873 39.1084 26.2944 38.5886 26.2944H37.1768C36.657 26.2944 36.2357 25.873 36.2357 25.3532V23.9414C36.2357 23.4216 36.657 23.0002 37.1768 23.0002Z" fill="#F9E2D5"/>
-<path d="M9.41202 14.5295H8.00026C7.48046 14.5295 7.05908 14.9509 7.05908 15.4707V16.8825C7.05908 17.4023 7.48046 17.8237 8.00026 17.8237H9.41202C9.93182 17.8237 10.3532 17.4023 10.3532 16.8825V15.4707C10.3532 14.9509 9.93182 14.5295 9.41202 14.5295Z" fill="#F9E2D5"/>
-<path d="M37.1768 14.5295H38.5886C39.1084 14.5295 39.5298 14.9509 39.5298 15.4707V16.8825C39.5298 17.4023 39.1084 17.8237 38.5886 17.8237H37.1768C36.657 17.8237 36.2357 17.4023 36.2357 16.8825V15.4707C36.2357 14.9509 36.657 14.5295 37.1768 14.5295Z" fill="#F9E2D5"/>
-<path d="M10.8238 26.7649H6.11793C5.59814 26.7649 5.17676 27.1863 5.17676 27.7061V29.1178C5.17676 29.6376 5.59814 30.059 6.11793 30.059H10.8238C11.3436 30.059 11.765 29.6376 11.765 29.1178V27.7061C11.765 27.1863 11.3436 26.7649 10.8238 26.7649Z" fill="#F9E2D5"/>
-<path d="M35.765 26.7649H40.4709C40.9907 26.7649 41.4121 27.1863 41.4121 27.7061V29.1178C41.4121 29.6376 40.9907 30.059 40.4709 30.059H35.765C35.2452 30.059 34.8239 29.6376 34.8239 29.1178V27.7061C34.8239 27.1863 35.2452 26.7649 35.765 26.7649Z" fill="#F9E2D5"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2356 14.5295H16.9415C17.4613 14.5295 17.8827 14.9509 17.8827 15.4707V16.8825C17.8827 17.4023 17.4613 17.8237 16.9415 17.8237H12.2356C11.7158 17.8237 11.2944 17.4023 11.2944 16.8825V15.4707C11.2944 14.9509 11.7158 14.5295 12.2356 14.5295Z" fill="#EFEDF8"/>
-<path d="M28.2354 14.5295H23.5296C23.0098 14.5295 22.5884 14.9509 22.5884 15.4707V16.8825C22.5884 17.4023 23.0098 17.8237 23.5296 17.8237H28.2354C28.7552 17.8237 29.1766 17.4023 29.1766 16.8825V15.4707C29.1766 14.9509 28.7552 14.5295 28.2354 14.5295Z" fill="#EFEDF8"/>
-<path d="M28.2354 23.0002H23.5296C23.0098 23.0002 22.5884 23.4216 22.5884 23.9414V25.3532C22.5884 25.873 23.0098 26.2944 23.5296 26.2944H28.2354C28.7552 26.2944 29.1766 25.873 29.1766 25.3532V23.9414C29.1766 23.4216 28.7552 23.0002 28.2354 23.0002Z" fill="#EFEDF8"/>
-<path d="M32.0002 26.7649H29.6472C29.1274 26.7649 28.7061 27.1863 28.7061 27.7061V29.1178C28.7061 29.6376 29.1274 30.059 29.6472 30.059H32.0002C32.52 30.059 32.9413 29.6376 32.9413 29.1178V27.7061C32.9413 27.1863 32.52 26.7649 32.0002 26.7649Z" fill="#EFEDF8"/>
-<path d="M22.5885 18.7649H17.8826C17.3628 18.7649 16.9414 19.1863 16.9414 19.7061V21.1178C16.9414 21.6376 17.3628 22.059 17.8826 22.059H22.5885C23.1083 22.059 23.5296 21.6376 23.5296 21.1178V19.7061C23.5296 19.1863 23.1083 18.7649 22.5885 18.7649Z" fill="#EFEDF8"/>
-<path d="M21.1767 14.5295H19.7649C19.2451 14.5295 18.8237 14.9509 18.8237 15.4707V16.8825C18.8237 17.4023 19.2451 17.8237 19.7649 17.8237H21.1767C21.6965 17.8237 22.1178 17.4023 22.1178 16.8825V15.4707C22.1178 14.9509 21.6965 14.5295 21.1767 14.5295Z" fill="#EFEDF8"/>
-<path d="M21.1767 23.0002H19.7649C19.2451 23.0002 18.8237 23.4216 18.8237 23.9414V25.3532C18.8237 25.873 19.2451 26.2944 19.7649 26.2944H21.1767C21.6965 26.2944 22.1178 25.873 22.1178 25.3532V23.9414C22.1178 23.4216 21.6965 23.0002 21.1767 23.0002Z" fill="#EFEDF8"/>
-<path d="M15.059 26.7649H13.6472C13.1274 26.7649 12.7061 27.1863 12.7061 27.7061V29.1178C12.7061 29.6376 13.1274 30.059 13.6472 30.059H15.059C15.5788 30.059 16.0002 29.6376 16.0002 29.1178V27.7061C16.0002 27.1863 15.5788 26.7649 15.059 26.7649Z" fill="#EFEDF8"/>
-<path d="M21.1767 31.0002H19.7649C19.2451 31.0002 18.8237 31.4216 18.8237 31.9414V33.3532C18.8237 33.873 19.2451 34.2944 19.7649 34.2944H21.1767C21.6965 34.2944 22.1178 33.873 22.1178 33.3532V31.9414C22.1178 31.4216 21.6965 31.0002 21.1767 31.0002Z" fill="#EFEDF8"/>
-<path d="M32.4706 23.0002H31.0589C30.5391 23.0002 30.1177 23.4216 30.1177 23.9414V25.3532C30.1177 25.873 30.5391 26.2944 31.0589 26.2944H32.4706C32.9904 26.2944 33.4118 25.873 33.4118 25.3532V23.9414C33.4118 23.4216 32.9904 23.0002 32.4706 23.0002Z" fill="#EFEDF8"/>
-<path d="M15.059 18.7649H13.6472C13.1274 18.7649 12.7061 19.1863 12.7061 19.7061V21.1178C12.7061 21.6376 13.1274 22.059 13.6472 22.059H15.059C15.5788 22.059 16.0002 21.6376 16.0002 21.1178V19.7061C16.0002 19.1863 15.5788 18.7649 15.059 18.7649Z" fill="#EFEDF8"/>
-<path d="M26.8241 18.7649H25.4124C24.8926 18.7649 24.4712 19.1863 24.4712 19.7061V21.1178C24.4712 21.6376 24.8926 22.059 25.4124 22.059H26.8241C27.3439 22.059 27.7653 21.6376 27.7653 21.1178V19.7061C27.7653 19.1863 27.3439 18.7649 26.8241 18.7649Z" fill="#EFEDF8"/>
-<path d="M32.4706 14.5295H31.0589C30.5391 14.5295 30.1177 14.9509 30.1177 15.4707V16.8825C30.1177 17.4023 30.5391 17.8237 31.0589 17.8237H32.4706C32.9904 17.8237 33.4118 17.4023 33.4118 16.8825V15.4707C33.4118 14.9509 32.9904 14.5295 32.4706 14.5295Z" fill="#EFEDF8"/>
-<path d="M34.3531 18.7649H29.6472C29.1274 18.7649 28.7061 19.1863 28.7061 19.7061V21.1178C28.7061 21.6376 29.1274 22.059 29.6472 22.059H34.3531C34.8729 22.059 35.2943 21.6376 35.2943 21.1178V19.7061C35.2943 19.1863 34.8729 18.7649 34.3531 18.7649Z" fill="#EFEDF8"/>
-<path d="M22.5885 26.7649H17.8826C17.3628 26.7649 16.9414 27.1863 16.9414 27.7061V29.1178C16.9414 29.6376 17.3628 30.059 17.8826 30.059H22.5885C23.1083 30.059 23.5296 29.6376 23.5296 29.1178V27.7061C23.5296 27.1863 23.1083 26.7649 22.5885 26.7649Z" fill="#EFEDF8"/>
-<path d="M28.2354 31.0002H23.5296C23.0098 31.0002 22.5884 31.4216 22.5884 31.9414V33.3532C22.5884 33.873 23.0098 34.2944 23.5296 34.2944H28.2354C28.7552 34.2944 29.1766 33.873 29.1766 33.3532V31.9414C29.1766 31.4216 28.7552 31.0002 28.2354 31.0002Z" fill="#EFEDF8"/>
-<path d="M26.8241 26.7649H25.4124C24.8926 26.7649 24.4712 27.1863 24.4712 27.7061V29.1178C24.4712 29.6376 24.8926 30.059 25.4124 30.059H26.8241C27.3439 30.059 27.7653 29.6376 27.7653 29.1178V27.7061C27.7653 27.1863 27.3439 26.7649 26.8241 26.7649Z" fill="#EFEDF8"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M23.5516 10.3323L36.0222 13.8791C36.4267 13.9942 36.7059 14.3638 36.7059 14.7844V21.1043C36.7059 25.151 35.0909 29.032 32.2166 31.8933L23.9581 40.1143C23.5909 40.4798 22.9973 40.4798 22.6301 40.1143L14.3717 31.8933C11.4972 29.032 9.88232 25.151 9.88232 21.1043V14.7844C9.88232 14.3638 10.1614 13.9942 10.566 13.8791L23.0366 10.3323C23.2049 10.2844 23.3833 10.2844 23.5516 10.3323ZM23.1301 12.4046L11.9603 15.5579C11.7575 15.6151 11.6175 15.8001 11.6175 16.0108V20.6639C11.6175 24.3243 13.0892 27.8348 15.7088 30.4231L22.9272 37.5553C23.1105 37.7364 23.4054 37.7364 23.5887 37.5553L30.807 30.4231C33.4268 27.8348 34.8983 24.3243 34.8983 20.6639V16.0108C34.8983 15.8001 34.7583 15.6151 34.5556 15.5579L23.3858 12.4046C23.3022 12.381 23.2137 12.381 23.1301 12.4046Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M23.3165 11.7436L34.14 14.8206C34.5447 14.9356 34.8238 15.3052 34.8238 15.7259V21.1793C34.8238 24.7274 33.4072 28.1301 30.8859 30.6389L23.723 37.7659C23.3558 38.1312 22.7625 38.1312 22.3953 37.7659L15.2325 30.6389C12.711 28.1301 11.2944 24.7274 11.2944 21.1793V15.7259C11.2944 15.3052 11.5736 14.9356 11.9782 14.8206L22.8018 11.7436C22.97 11.6958 23.1483 11.6958 23.3165 11.7436ZM22.8996 13.5561L13.1593 16.3045C12.9566 16.3617 12.8166 16.5467 12.8166 16.7574V20.7932C12.8166 24.0026 14.1075 27.0805 16.4054 29.3498L22.6968 35.5631C22.88 35.7442 23.1748 35.7442 23.3581 35.5631L29.6494 29.3498C31.9474 27.0805 33.2383 24.0026 33.2383 20.7932V16.7574C33.2383 16.5467 33.0983 16.3617 32.8955 16.3045L23.1552 13.5561C23.0717 13.5325 22.9832 13.5325 22.8996 13.5561Z" fill="#6E49CB"/>
-</svg>
diff --git a/app/assets/images/learn_gitlab/trial_started.svg b/app/assets/images/learn_gitlab/trial_started.svg
deleted file mode 100644
index 42d6fb6c013..00000000000
--- a/app/assets/images/learn_gitlab/trial_started.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<svg width="32" height="30" viewBox="0 0 32 30" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9653 29.8263L21.8368 11.6285H10.0933L15.9653 29.8263Z" fill="#E38800"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9656 29.8263L10.0936 11.6285H1.86475L15.9656 29.8263Z" fill="#F7980A"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M1.86441 11.6285L0.0800968 17.1586C-0.0826524 17.663 0.0955967 18.2156 0.521693 18.5273L15.9652 29.8261L1.86441 11.6285Z" fill="#FCA326"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M1.86426 11.6286H10.0933L6.55678 0.668335C6.37489 0.104294 5.58257 0.104447 5.40067 0.668335L1.86426 11.6286Z" fill="#E38800"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M15.9653 29.8263L21.8369 11.6285H30.0658L15.9653 29.8263Z" fill="#F7980A"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M30.0662 11.6285L31.8505 17.1586C32.0132 17.663 31.835 18.2156 31.4089 18.5273L15.9653 29.8261L30.0662 11.6285Z" fill="#FCA326"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M30.066 11.6286H21.8369L25.3735 0.668335C25.5554 0.104294 26.3477 0.104447 26.5296 0.668335L30.066 11.6286Z" fill="#E38800"/>
-</svg>
diff --git a/app/assets/images/learn_gitlab/user_added.svg b/app/assets/images/learn_gitlab/user_added.svg
deleted file mode 100644
index efbccff0bbb..00000000000
--- a/app/assets/images/learn_gitlab/user_added.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<svg width="38" height="24" viewBox="0 0 38 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M33.6353 7.51765H33.8824L32.4706 5.80941L31.0588 7.50353L29.6471 5.80941L28.2353 7.50353L26.8235 5.80941L25.4118 7.50353L24 5.80941L22.5882 7.51765H23.1318C23.6965 8.89412 24 10.4118 24 12C24 14.0047 23.5059 15.8824 22.6447 17.5482C24.0353 19.1576 26.0541 20.1176 28.2353 20.1176C32.3294 20.1176 35.6471 16.7718 35.6471 12.6353C35.6471 10.6588 34.8847 8.85177 33.6353 7.51765ZM22.0094 5.36471C23.7035 3.88235 25.9059 3.03529 28.2353 3.03529C33.5012 3.03529 37.7647 7.34118 37.7647 12.6353C37.7647 17.9294 33.5012 22.2353 28.2353 22.2353C25.6376 22.2353 23.2235 21.1765 21.4588 19.3835C19.2706 22.1929 15.84 24 12 24C5.36471 24 0 18.6353 0 12C0 5.36471 5.36471 0 12 0C14.2729 0 16.3976 0.635295 18.2118 1.72941C19.7153 2.64706 21.0141 3.88235 22.0094 5.37177V5.36471ZM3.52941 8.47059C3.07059 9.55765 2.82353 10.7506 2.82353 12C2.82353 17.0682 6.93177 21.1765 12 21.1765C17.0682 21.1765 21.1765 17.0682 21.1765 12C21.1765 10.7506 20.9294 9.55765 20.4706 8.47059H14.1176C13.7435 8.47059 13.3835 8.32941 13.1294 8.04706L12 6.94588L10.8706 8.06118C10.6165 8.34353 10.2565 8.48471 9.88235 8.48471H3.52941V8.47059ZM18.6212 5.64706C16.9271 3.88235 14.5553 2.82353 12 2.82353C9.44471 2.82353 7.07294 3.88235 5.37882 5.64706H9.29647L11.0047 3.95294C11.5271 3.38824 12.4165 3.38824 12.9812 3.95294L14.6753 5.64706H18.5859H18.6212Z" fill="#E1DBF2"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M25.1292 14.3435C24.6633 14.3435 24.2821 13.9623 24.2821 13.4964C24.2821 13.0164 24.6633 12.6353 25.1292 12.6353C25.5951 12.6353 25.9762 13.0164 25.9762 13.4823C25.9762 13.9623 25.5951 14.3435 25.1292 14.3435ZM31.3409 14.3435C30.8751 14.3435 30.4939 13.9623 30.4939 13.4964C30.4939 13.0164 30.8751 12.6353 31.3409 12.6353C31.8068 12.6353 32.188 13.0164 32.188 13.4823C32.188 13.9623 31.8068 14.3435 31.3409 14.3435ZM9.17624 15.5294H14.8233C14.8233 17.0823 13.5527 18.3529 11.9998 18.3529C10.4468 18.3529 9.17624 17.0823 9.17624 15.5294ZM8.11742 14.8235C7.53153 14.8235 7.05859 14.3505 7.05859 13.7647C7.05859 13.1788 7.53153 12.7058 8.11742 12.7058C8.7033 12.7058 9.17624 13.1788 9.17624 13.7647C9.17624 14.3505 8.7033 14.8235 8.11742 14.8235ZM15.8821 14.8235C15.2962 14.8235 14.8233 14.3505 14.8233 13.7647C14.8233 13.1788 15.2962 12.7058 15.8821 12.7058C16.468 12.7058 16.9409 13.1788 16.9409 13.7647C16.9409 14.3505 16.468 14.8235 15.8821 14.8235Z" fill="#6B4FBB"/>
-</svg>
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
index e9d19f18ab5..57d1f135606 100644
--- a/app/assets/javascripts/alert_management/list.js
+++ b/app/assets/javascripts/alert_management/list.js
@@ -39,6 +39,7 @@ export default () => {
return defaultDataIdFromObject(object);
},
},
+ assumeImmutableResults: true,
},
),
});
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
index 84189b675f2..52901d4c5bb 100644
--- a/app/assets/javascripts/analytics/shared/utils.js
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -1,4 +1,9 @@
+import dateFormat from 'dateformat';
+import { dateFormats } from './constants';
+
export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
if (!searchTerm?.length) return data;
return data.filter((item) => item[filterByKey].toLowerCase().includes(searchTerm.toLowerCase()));
};
+
+export const toYmd = (date) => dateFormat(date, dateFormats.isoDate);
diff --git a/app/assets/javascripts/analytics/usage_trends/index.js b/app/assets/javascripts/analytics/usage_trends/index.js
index d1880b09f15..3e85832edcf 100644
--- a/app/assets/javascripts/analytics/usage_trends/index.js
+++ b/app/assets/javascripts/analytics/usage_trends/index.js
@@ -6,7 +6,7 @@ import UsageTrendsApp from './components/app.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export default () => {
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 84a5d5ae4b3..01e463c1965 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -870,6 +870,14 @@ const Api = {
return axios.put(url, freezePeriod);
},
+ deleteFreezePeriod(id, freezePeriodId) {
+ const url = Api.buildUrl(this.freezePeriodPath)
+ .replace(':id', encodeURIComponent(id))
+ .replace(':freeze_period_id', encodeURIComponent(freezePeriodId));
+
+ return axios.delete(url);
+ },
+
trackRedisCounterEvent(event) {
if (!gon.features?.usageDataApi) {
return null;
diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js
index 1cd7fb0b954..b018db9a02d 100644
--- a/app/assets/javascripts/api/projects_api.js
+++ b/app/assets/javascripts/api/projects_api.js
@@ -3,6 +3,7 @@ import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
const PROJECTS_PATH = '/api/:version/projects.json';
+const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id';
export function getProjects(query, options, callback = () => {}) {
const url = buildApiUrl(PROJECTS_PATH);
@@ -25,3 +26,10 @@ export function getProjects(query, options, callback = () => {}) {
return { data, headers };
});
}
+
+export function importProjectMembers(sourceId, targetId) {
+ const url = buildApiUrl(PROJECT_IMPORT_MEMBERS_PATH)
+ .replace(':id', sourceId)
+ .replace(':project_id', targetId);
+ return axios.post(url);
+}
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
index f89600fbed3..fe801cd460f 100644
--- a/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
+++ b/app/assets/javascripts/authentication/two_factor_auth/components/recovery_codes.vue
@@ -165,7 +165,7 @@ export default {
:title="$options.i18n.proceedButton"
variant="confirm"
data-qa-selector="proceed_button"
- data-track-event="click_button"
+ data-track-action="click_button"
:data-track-label="`${$options.trackingLabelPrefix}proceed_button`"
>{{ $options.i18n.proceedButton }}</gl-button
>
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 0a05e0d44ce..8381dcec9c3 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -6,7 +6,7 @@ export default class Autosave {
constructor(field, key, fallbackKey, lockVersion) {
this.field = field;
- this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
if (key.join != null) {
key = key.join('/');
}
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 7e605099655..2c7e878f044 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui';
+import { GlLoadingIcon, GlFormInput, GlFormGroup, GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import { escape, debounce } from 'lodash';
import { mapActions, mapState } from 'vuex';
import createFlash from '~/flash';
@@ -19,6 +18,9 @@ export default {
GlFormInput,
GlFormGroup,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
isEditing: {
type: Boolean,
@@ -168,6 +170,7 @@ export default {
});
},
},
+ safeHtmlConfig: { ALLOW_TAGS: ['a', 'code'] },
};
</script>
@@ -184,7 +187,7 @@ export default {
<div class="form-group">
<label for="badge-link-url" class="label-bold">{{ s__('Badges|Link') }}</label>
- <p v-html="helpText"></p>
+ <p v-safe-html:[$options.safeHtmlConfig]="helpText"></p>
<input
id="badge-link-url"
v-model="linkUrl"
@@ -199,7 +202,7 @@ export default {
<div class="form-group">
<label for="badge-image-url" class="label-bold">{{ s__('Badges|Badge image URL') }}</label>
- <p v-html="helpText"></p>
+ <p v-safe-html:[$options.safeHtmlConfig]="helpText"></p>
<input
id="badge-image-url"
v-model="imageUrl"
diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue
index 96c3b8276ee..f5e3bab6ff0 100644
--- a/app/assets/javascripts/batch_comments/components/draft_note.vue
+++ b/app/assets/javascripts/batch_comments/components/draft_note.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlButton } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import NoteableNote from '~/notes/components/noteable_note.vue';
@@ -106,7 +105,7 @@ export default {
<div
v-if="draftCommands"
class="referenced-commands draft-note-commands"
- v-html="draftCommands"
+ v-html="draftCommands /* eslint-disable-line vue/no-v-html */"
></div>
<p class="draft-note-actions d-flex">
diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue
index 080a5543e53..bce13751448 100644
--- a/app/assets/javascripts/batch_comments/components/review_bar.vue
+++ b/app/assets/javascripts/batch_comments/components/review_bar.vue
@@ -1,5 +1,6 @@
<script>
import { mapActions, mapGetters } from 'vuex';
+import { REVIEW_BAR_VISIBLE_CLASS_NAME } from '../constants';
import PreviewDropdown from './preview_dropdown.vue';
import PublishButton from './publish_button.vue';
@@ -10,7 +11,6 @@ export default {
},
computed: {
...mapGetters(['isNotesFetched']),
- ...mapGetters('batchComments', ['draftsCount']),
},
watch: {
isNotesFetched() {
@@ -19,13 +19,19 @@ export default {
}
},
},
+ mounted() {
+ document.body.classList.add(REVIEW_BAR_VISIBLE_CLASS_NAME);
+ },
+ beforeDestroy() {
+ document.body.classList.remove(REVIEW_BAR_VISIBLE_CLASS_NAME);
+ },
methods: {
...mapActions('batchComments', ['expandAllDiscussions']),
},
};
</script>
<template>
- <div v-show="draftsCount > 0">
+ <div>
<nav class="review-bar-component" data-testid="review_bar_component">
<div
class="review-bar-content d-flex gl-justify-content-end"
diff --git a/app/assets/javascripts/batch_comments/constants.js b/app/assets/javascripts/batch_comments/constants.js
index b309c339fc8..5e026251e0b 100644
--- a/app/assets/javascripts/batch_comments/constants.js
+++ b/app/assets/javascripts/batch_comments/constants.js
@@ -1,3 +1,5 @@
export const CHANGES_TAB = 'diffs';
export const DISCUSSION_TAB = 'notes';
export const SHOW_TAB = 'show';
+
+export const REVIEW_BAR_VISIBLE_CLASS_NAME = 'review-bar-visible';
diff --git a/app/assets/javascripts/batch_comments/index.js b/app/assets/javascripts/batch_comments/index.js
index 9c763e70d63..65fd34dcb00 100644
--- a/app/assets/javascripts/batch_comments/index.js
+++ b/app/assets/javascripts/batch_comments/index.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
-import { mapActions } from 'vuex';
+import { mapActions, mapGetters } from 'vuex';
import store from '~/mr_notes/stores';
-import ReviewBar from './components/review_bar.vue';
export const initReviewBar = () => {
const el = document.getElementById('js-review-bar');
@@ -10,6 +9,12 @@ export const initReviewBar = () => {
new Vue({
el,
store,
+ components: {
+ ReviewBar: () => import('./components/review_bar.vue'),
+ },
+ computed: {
+ ...mapGetters('batchComments', ['draftsCount']),
+ },
mounted() {
this.fetchDrafts();
},
@@ -17,7 +22,9 @@ export const initReviewBar = () => {
...mapActions('batchComments', ['fetchDrafts']),
},
render(createElement) {
- return createElement(ReviewBar);
+ if (this.draftsCount === 0) return null;
+
+ return createElement('review-bar');
},
});
};
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/playable.js b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
index 33bb6e0c31c..2b667aba2d6 100644
--- a/app/assets/javascripts/behaviors/markdown/nodes/playable.js
+++ b/app/assets/javascripts/behaviors/markdown/nodes/playable.js
@@ -3,7 +3,6 @@
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
import { Node } from 'tiptap';
-import { HIGHER_PARSE_RULE_PRIORITY } from '../constants';
/**
* Abstract base class for playable media, like video and audio.
@@ -33,33 +32,33 @@ export default class Playable extends Node {
const parseDOM = [
{
tag: `.${this.mediaType}-container`,
- skip: true,
- },
- {
- tag: `.${this.mediaType}-container p`,
- priority: HIGHER_PARSE_RULE_PRIORITY,
- ignore: true,
- },
- {
- tag: `${this.mediaType}[src]`,
- getAttrs: (el) => ({ src: el.src, alt: el.dataset.title }),
+ getAttrs: (el) => ({
+ src: el.querySelector(this.mediaType).src,
+ alt: el.querySelector(this.mediaType).dataset.title,
+ }),
},
];
const toDOM = (node) => [
- this.mediaType,
- {
- src: node.attrs.src,
- controls: true,
- 'data-setup': '{}',
- 'data-title': node.attrs.alt,
- ...this.extraElementAttrs,
- },
+ 'span',
+ { class: `media-container ${this.mediaType}-container` },
+ [
+ this.mediaType,
+ {
+ src: node.attrs.src,
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ ...this.extraElementAttrs,
+ },
+ ],
+ ['a', { href: node.attrs.src }, node.attrs.alt],
];
return {
attrs,
- group: 'block',
+ group: 'inline',
+ inline: true,
draggable: true,
parseDOM,
toDOM,
@@ -68,6 +67,5 @@ export default class Playable extends Node {
toMarkdown(state, node) {
defaultMarkdownSerializer.nodes.image(state, node);
- state.closeBlock(node);
}
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
index 005ef103ded..ebf2ab0381e 100644
--- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js
+++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js
@@ -19,7 +19,7 @@ export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
*/
export const getCustomizations = memoize(() => {
let parsedCustomizations = {};
- const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+ const localStorageIsSafe = AccessorUtilities.canUseLocalStorage();
if (localStorageIsSafe) {
try {
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
index 8f1518a1c9c..cf7a71d4206 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_toggle.vue
@@ -13,7 +13,7 @@ export default {
},
data() {
return {
- localStorageUsable: AccessorUtilities.isLocalStorageAccessSafe(),
+ localStorageUsable: AccessorUtilities.canUseLocalStorage(),
shortcutsEnabled: !shouldDisableShortcuts(),
};
},
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index 470c679b8ba..387d6043315 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -31,7 +31,6 @@ export default class BlobFileDropzone {
autoProcessQueue: false,
url: form.attr('action'),
// Rails uses a hidden input field for PUT
- // http://stackoverflow.com/questions/21056482/how-to-set-method-put-in-form-tag-in-rails
method,
clickable: true,
uploadMultiple: false,
diff --git a/app/assets/javascripts/blob/notebook/index.js b/app/assets/javascripts/blob/notebook/index.js
index a8c94b6263e..25fe29c4fbe 100644
--- a/app/assets/javascripts/blob/notebook/index.js
+++ b/app/assets/javascripts/blob/notebook/index.js
@@ -6,6 +6,9 @@ export default () => {
return new Vue({
el,
+ provide: {
+ relativeRawPath: el.dataset.relativeRawPath,
+ },
render(createElement) {
return createElement(NotebookViewer, {
props: {
diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
index 02f93e14219..d2a841c88f1 100644
--- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue
+++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue
@@ -77,3 +77,9 @@ export default {
</p>
</div>
</template>
+
+<style>
+.output img {
+ min-width: 0; /* https://www.w3.org/TR/css-flexbox-1/#min-size-auto */
+}
+</style>
diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
index fdaa4b082f7..a3278f8bde2 100644
--- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
+++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue
@@ -124,7 +124,7 @@ export default {
:href="goToMergeRequestPath"
:data-track-property="humanAccess"
:data-track-value="$options.goToTrackValueMergeRequest"
- :data-track-event="$options.trackEvent"
+ :data-track-action="$options.trackEvent"
:data-track-label="trackLabel"
>
{{ $options.i18n.mergeRequestButton }}
@@ -135,7 +135,7 @@ export default {
variant="success"
:data-track-property="humanAccess"
:data-track-value="$options.goToTrackValuePipelines"
- :data-track-event="$options.trackEvent"
+ :data-track-action="$options.trackEvent"
:data-track-label="trackLabel"
>
{{ $options.i18n.pipelinesButton }}
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 aee8bf15e44..e0b0857f7b4 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
@@ -121,7 +121,7 @@ export default {
icon="close"
:data-track-property="humanAccess"
:data-track-value="$options.dismissTrackValue"
- :data-track-event="$options.clickTrackValue"
+ :data-track-action="$options.clickTrackValue"
:data-track-label="trackLabel"
@click="onDismiss"
/>
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
index 3219d74f85f..d113a1d39d8 100644
--- a/app/assets/javascripts/boards/boards_util.js
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -1,6 +1,5 @@
import { sortBy, cloneDeep } from 'lodash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { ListType } from './constants';
+import { ListType, MilestoneIDs } from './constants';
export function getMilestone() {
return null;
@@ -49,12 +48,10 @@ export function formatListIssues(listIssues) {
return {
...map,
[list.id]: sortedIssues.map((i) => {
- const id = getIdFromGraphQLId(i.id);
+ const { id } = i;
const listIssue = {
...i,
- id,
- fullId: i.id,
labels: i.labels?.nodes || [],
assignees: i.assignees?.nodes || [],
};
@@ -108,7 +105,10 @@ export function formatIssueInput(issueInput, boardConfig) {
return {
...issueInput,
- milestoneId: milestoneId ? fullMilestoneId(milestoneId) : null,
+ milestoneId:
+ milestoneId && milestoneId !== MilestoneIDs.ANY
+ ? fullMilestoneId(milestoneId)
+ : issueInput?.milestoneId,
labelIds: [...labelIds, ...(labels?.map((l) => fullLabelId(l)) || [])],
assigneeIds: [...assigneeIds, ...(assigneeId ? [fullUserId(assigneeId)] : [])],
};
diff --git a/app/assets/javascripts/boards/components/board_add_new_column.vue b/app/assets/javascripts/boards/components/board_add_new_column.vue
index d4b559add6e..22ad619e76b 100644
--- a/app/assets/javascripts/boards/components/board_add_new_column.vue
+++ b/app/assets/javascripts/boards/components/board_add_new_column.vue
@@ -2,9 +2,6 @@
import { GlFormRadio, GlFormRadioGroup, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardAddNewColumnForm from '~/boards/components/board_add_new_column_form.vue';
-import { ListType } from '~/boards/constants';
-import boardsStore from '~/boards/stores/boards_store';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export default {
components: {
@@ -24,7 +21,7 @@ export default {
},
computed: {
...mapState(['labels', 'labelsLoading']),
- ...mapGetters(['getListByLabelId', 'shouldUseGraphQL']),
+ ...mapGetters(['getListByLabelId']),
columnForSelected() {
return this.getListByLabelId(this.selectedId);
},
@@ -34,17 +31,6 @@ export default {
},
methods: {
...mapActions(['createList', 'fetchLabels', 'highlightList', 'setAddColumnFormVisibility']),
- highlight(listId) {
- if (this.shouldUseGraphQL) {
- this.highlightList(listId);
- } else {
- const list = boardsStore.state.lists.find(({ id }) => id === listId);
- list.highlighted = true;
- setTimeout(() => {
- list.highlighted = false;
- }, 2000);
- }
- },
addList() {
if (!this.selectedLabel) {
return;
@@ -54,23 +40,11 @@ export default {
if (this.columnForSelected) {
const listId = this.columnForSelected.id;
- this.highlight(listId);
+ this.highlightList(listId);
return;
}
- if (this.shouldUseGraphQL) {
- this.createList({ labelId: this.selectedId });
- } else {
- const listObj = {
- labelId: getIdFromGraphQLId(this.selectedId),
- title: this.selectedLabel.title,
- position: boardsStore.state.lists.length - 2,
- list_type: ListType.label,
- label: this.selectedLabel,
- };
-
- boardsStore.new(listObj);
- }
+ this.createList({ labelId: this.selectedId });
},
filterItems(searchTerm) {
diff --git a/app/assets/javascripts/boards/components/board_app.vue b/app/assets/javascripts/boards/components/board_app.vue
new file mode 100644
index 00000000000..28f4a267077
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_app.vue
@@ -0,0 +1,29 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import BoardContent from '~/boards/components/board_content.vue';
+import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue';
+
+export default {
+ components: {
+ BoardContent,
+ BoardSettingsSidebar,
+ },
+ inject: ['disabled'],
+ computed: {
+ ...mapGetters(['isSidebarOpen']),
+ },
+ mounted() {
+ this.performSearch();
+ },
+ methods: {
+ ...mapActions(['performSearch']),
+ },
+};
+</script>
+
+<template>
+ <div class="boards-app gl-relative" :class="{ 'is-compact': isSidebarOpen }">
+ <board-content :disabled="disabled" />
+ <board-settings-sidebar />
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_card_deprecated.vue b/app/assets/javascripts/boards/components/board_card_deprecated.vue
deleted file mode 100644
index e12a2836f67..00000000000
--- a/app/assets/javascripts/boards/components/board_card_deprecated.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-<script>
-// This component is being replaced in favor of './board_card.vue' for GraphQL boards
-import sidebarEventHub from '~/sidebar/event_hub';
-import eventHub from '../eventhub';
-import boardsStore from '../stores/boards_store';
-import BoardCardLayoutDeprecated from './board_card_layout_deprecated.vue';
-
-export default {
- components: {
- BoardCardLayout: BoardCardLayoutDeprecated,
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- issue: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- },
- methods: {
- // These are methods instead of computed's, because boardsStore is not reactive.
- isActive() {
- return this.getActiveId() === this.issue.id;
- },
- getActiveId() {
- return boardsStore.detail?.issue?.id;
- },
- showIssue({ isMultiSelect }) {
- // If no issues are opened, close all sidebars first
- if (!this.getActiveId()) {
- sidebarEventHub.$emit('sidebar.closeAll');
- }
- if (this.isActive()) {
- eventHub.$emit('clearDetailIssue', isMultiSelect);
-
- if (isMultiSelect) {
- eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
- }
- } else {
- eventHub.$emit('newDetailIssue', this.issue, isMultiSelect);
- boardsStore.setListDetail(this.list);
- }
- },
- },
-};
-</script>
-
-<template>
- <board-card-layout
- data-qa-selector="board_card"
- :issue="issue"
- :list="list"
- :is-active="isActive()"
- v-bind="$attrs"
- @show="showIssue"
- />
-</template>
diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue
index 5658a34e9a6..db80d48239b 100644
--- a/app/assets/javascripts/boards/components/board_card_inner.vue
+++ b/app/assets/javascripts/boards/components/board_card_inner.vue
@@ -214,10 +214,19 @@ export default {
class="confidential-icon gl-mr-2"
:aria-label="__('Confidential')"
/>
+ <gl-icon
+ v-if="item.hidden"
+ v-gl-tooltip
+ name="spam"
+ :title="__('This issue is hidden because its author has been banned')"
+ class="gl-mr-2 hidden-icon"
+ data-testid="hidden-icon"
+ />
<a
:href="item.path || item.webUrl || ''"
:title="item.title"
:class="{ 'gl-text-gray-400!': item.isLoading }"
+ class="js-no-trigger"
@mousemove.stop
>{{ item.title }}</a
>
diff --git a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue b/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue
deleted file mode 100644
index 3381e4c3a7d..00000000000
--- a/app/assets/javascripts/boards/components/board_card_layout_deprecated.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-<script>
-import { mapActions, mapGetters } from 'vuex';
-import { ISSUABLE } from '~/boards/constants';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import boardsStore from '../stores/boards_store';
-import IssueCardInnerDeprecated from './issue_card_inner_deprecated.vue';
-
-export default {
- name: 'BoardCardLayout',
- components: {
- IssueCardInner: IssueCardInnerDeprecated,
- },
- mixins: [glFeatureFlagMixin()],
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- issue: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- default: false,
- required: false,
- },
- index: {
- type: Number,
- default: 0,
- required: false,
- },
- isActive: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- showDetail: false,
- multiSelect: boardsStore.multiSelect,
- };
- },
- computed: {
- ...mapGetters(['isSwimlanesOn']),
- multiSelectVisible() {
- return this.multiSelect.list.findIndex((issue) => issue.id === this.issue.id) > -1;
- },
- },
- methods: {
- ...mapActions(['setActiveId']),
- mouseDown() {
- this.showDetail = true;
- },
- mouseMove() {
- this.showDetail = false;
- },
- showIssue(e) {
- // Don't do anything if this happened on a no trigger element
- if (e.target.classList.contains('js-no-trigger')) return;
-
- if (this.glFeatures.graphqlBoardLists || this.isSwimlanesOn) {
- this.setActiveId({ id: this.issue.id, sidebarType: ISSUABLE });
- return;
- }
-
- const isMultiSelect = e.ctrlKey || e.metaKey;
-
- if (this.showDetail || isMultiSelect) {
- this.showDetail = false;
- this.$emit('show', { event: e, isMultiSelect });
- }
- },
- },
-};
-</script>
-
-<template>
- <li
- :class="{
- 'multi-select': multiSelectVisible,
- 'user-can-drag': !disabled && issue.id,
- 'is-disabled': disabled || !issue.id,
- 'is-active': isActive,
- }"
- :index="index"
- :data-issue-id="issue.id"
- :data-issue-iid="issue.iid"
- :data-issue-path="issue.referencePath"
- data-testid="board_card"
- class="board-card gl-p-5 gl-rounded-base"
- @mousedown="mouseDown"
- @mousemove="mouseMove"
- @mouseup="showIssue($event)"
- >
- <issue-card-inner :list="list" :issue="issue" :update-filters="true" />
- </li>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_column_deprecated.vue b/app/assets/javascripts/boards/components/board_column_deprecated.vue
deleted file mode 100644
index 7c090dfaa53..00000000000
--- a/app/assets/javascripts/boards/components/board_column_deprecated.vue
+++ /dev/null
@@ -1,112 +0,0 @@
-<script>
-// This component is being replaced in favor of './board_column.vue' for GraphQL boards
-import Sortable from 'sortablejs';
-import BoardListHeader from 'ee_else_ce/boards/components/board_list_header_deprecated.vue';
-import { getBoardSortableDefaultOptions, sortableEnd } from '../mixins/sortable_default_options';
-import boardsStore from '../stores/boards_store';
-import BoardList from './board_list_deprecated.vue';
-
-export default {
- components: {
- BoardListHeader,
- BoardList,
- },
- inject: {
- boardId: {
- default: '',
- },
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- required: true,
- },
- },
- data() {
- return {
- detailIssue: boardsStore.detail,
- filter: boardsStore.filter,
- };
- },
- computed: {
- listIssues() {
- return this.list.issues;
- },
- },
- watch: {
- filter: {
- handler() {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.page = 1;
- this.list.getIssues(true).catch(() => {
- // TODO: handle request error
- });
- },
- deep: true,
- },
- 'list.highlighted': {
- handler(highlighted) {
- if (highlighted) {
- this.$nextTick(() => {
- this.$el.scrollIntoView({ behavior: 'smooth', block: 'start' });
- });
- }
- },
- immediate: true,
- },
- },
- mounted() {
- const instance = this;
-
- const sortableOptions = getBoardSortableDefaultOptions({
- disabled: this.disabled,
- group: 'boards',
- draggable: '.is-draggable',
- handle: '.js-board-handle',
- onEnd(e) {
- sortableEnd();
-
- const sortable = this;
-
- if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
- const order = sortable.toArray();
- const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
-
- instance.$nextTick(() => {
- boardsStore.moveList(list, order);
- });
- }
- },
- });
-
- Sortable.create(this.$el.parentNode, sortableOptions);
- },
-};
-</script>
-
-<template>
- <div
- :class="{
- 'is-draggable': !list.preset,
- 'is-expandable': list.isExpandable,
- 'is-collapsed': !list.isExpanded,
- 'board-type-assignee': list.type === 'assignee',
- }"
- :data-id="list.id"
- class="board gl-display-inline-block gl-h-full gl-px-3 gl-vertical-align-top gl-white-space-normal"
- data-qa-selector="board_list"
- >
- <div
- class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base"
- :class="{ 'board-column-highlighted': list.highlighted }"
- >
- <board-list-header :list="list" :disabled="disabled" />
- <board-list ref="board-list" :disabled="disabled" :issues="listIssues" :list="list" />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue
index 4df6ff75249..27ea2e7a608 100644
--- a/app/assets/javascripts/boards/components/board_content.vue
+++ b/app/assets/javascripts/boards/components/board_content.vue
@@ -5,31 +5,22 @@ import Draggable from 'vuedraggable';
import { mapState, mapGetters, mapActions } from 'vuex';
import BoardAddNewColumn from 'ee_else_ce/boards/components/board_add_new_column.vue';
import defaultSortableConfig from '~/sortable/sortable_config';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DraggableItemTypes } from '../constants';
import BoardColumn from './board_column.vue';
-import BoardColumnDeprecated from './board_column_deprecated.vue';
export default {
draggableItemTypes: DraggableItemTypes,
components: {
BoardAddNewColumn,
BoardColumn,
- BoardColumnDeprecated,
BoardContentSidebar: () => import('~/boards/components/board_content_sidebar.vue'),
EpicBoardContentSidebar: () =>
import('ee_component/boards/components/epic_board_content_sidebar.vue'),
EpicsSwimlanes: () => import('ee_component/boards/components/epics_swimlanes.vue'),
GlAlert,
},
- mixins: [glFeatureFlagMixin()],
inject: ['canAdminList'],
props: {
- lists: {
- type: Array,
- required: false,
- default: () => [],
- },
disabled: {
type: Boolean,
required: true,
@@ -37,20 +28,15 @@ export default {
},
computed: {
...mapState(['boardLists', 'error', 'addColumnForm']),
- ...mapGetters(['isSwimlanesOn', 'isEpicBoard']),
- useNewBoardColumnComponent() {
- return this.glFeatures.graphqlBoardLists || this.isSwimlanesOn || this.isEpicBoard;
- },
+ ...mapGetters(['isSwimlanesOn', 'isEpicBoard', 'isIssueBoard']),
addColumnFormVisible() {
return this.addColumnForm?.visible;
},
boardListsToUse() {
- return this.useNewBoardColumnComponent
- ? sortBy([...Object.values(this.boardLists)], 'position')
- : this.lists;
+ return sortBy([...Object.values(this.boardLists)], 'position');
},
canDragColumns() {
- return (this.isEpicBoard || this.glFeatures.graphqlBoardLists) && this.canAdminList;
+ return this.canAdminList;
},
boardColumnWrapper() {
return this.canDragColumns ? Draggable : 'div';
@@ -68,9 +54,6 @@ export default {
return this.canDragColumns ? options : {};
},
- boardColumnComponent() {
- return this.useNewBoardColumnComponent ? BoardColumn : BoardColumnDeprecated;
- },
},
methods: {
...mapActions(['moveList', 'unsetError']),
@@ -95,8 +78,7 @@ export default {
class="boards-list gl-w-full gl-py-5 gl-px-3 gl-white-space-nowrap"
@end="moveList"
>
- <component
- :is="boardColumnComponent"
+ <board-column
v-for="(list, index) in boardListsToUse"
:key="index"
ref="board"
@@ -118,10 +100,7 @@ export default {
:disabled="disabled"
/>
- <board-content-sidebar
- v-if="isSwimlanesOn || glFeatures.graphqlBoardLists"
- data-testid="issue-boards-sidebar"
- />
+ <board-content-sidebar v-if="isIssueBoard" data-testid="issue-boards-sidebar" />
<epic-board-content-sidebar v-else-if="isEpicBoard" data-testid="epic-boards-sidebar" />
</div>
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index 7a936e75676..e0105d63d99 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -96,7 +96,7 @@ export default {
<template #header>
<sidebar-todo-widget
class="gl-mt-3"
- :issuable-id="activeBoardItem.fullId"
+ :issuable-id="activeBoardItem.id"
:issuable-iid="activeBoardItem.iid"
:full-path="fullPath"
:issuable-type="issuableType"
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index a89f71504a9..e939f0c0ebe 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -1,8 +1,7 @@
<script>
import { GlModal, GlAlert } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
-import ListLabel from '~/boards/models/label';
-import { TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants';
+import { TYPE_USER, TYPE_ITERATION, TYPE_MILESTONE } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { getParameterByName, visitUrl } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
@@ -189,7 +188,9 @@ export default {
issueBoardScopeMutationVariables() {
return {
weight: this.board.weight,
- assigneeId: this.board.assignee?.id || null,
+ assigneeId: this.board.assignee?.id
+ ? convertToGraphQLId(TYPE_USER, this.board.assignee.id)
+ : null,
milestoneId: this.board.milestone?.id
? convertToGraphQLId(TYPE_MILESTONE, this.board.milestone.id)
: null,
@@ -289,14 +290,10 @@ export default {
setBoardLabels(labels) {
labels.forEach((label) => {
if (label.set && !this.board.labels.find((l) => l.id === label.id)) {
- this.board.labels.push(
- new ListLabel({
- id: label.id,
- title: label.title,
- color: label.color,
- textColor: label.text_color,
- }),
- );
+ this.board.labels.push({
+ ...label,
+ textColor: label.text_color,
+ });
} else if (!label.set) {
this.board.labels = this.board.labels.filter((selected) => selected.id !== label.id);
}
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 849492effab..47dffc985aa 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -208,7 +208,7 @@ export default {
newIndex = children.length;
}
- const getItemId = (el) => Number(el.dataset.itemId);
+ const getItemId = (el) => el.dataset.itemId;
// If item is being moved within the same list
if (from === to) {
@@ -234,7 +234,7 @@ export default {
}
this.moveItem({
- itemId: Number(itemId),
+ itemId,
itemIid,
itemPath,
fromListId: from.dataset.listId,
diff --git a/app/assets/javascripts/boards/components/board_list_deprecated.vue b/app/assets/javascripts/boards/components/board_list_deprecated.vue
deleted file mode 100644
index fabaf7a85f5..00000000000
--- a/app/assets/javascripts/boards/components/board_list_deprecated.vue
+++ /dev/null
@@ -1,459 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import { Sortable, MultiDrag } from 'sortablejs';
-import createFlash from '~/flash';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { sprintf, __ } from '~/locale';
-import eventHub from '../eventhub';
-import {
- getBoardSortableDefaultOptions,
- sortableStart,
- sortableEnd,
-} from '../mixins/sortable_default_options';
-import boardsStore from '../stores/boards_store';
-import boardCard from './board_card_deprecated.vue';
-import boardNewIssue from './board_new_issue_deprecated.vue';
-
-// This component is being replaced in favor of './board_list.vue' for GraphQL boards
-
-Sortable.mount(new MultiDrag());
-
-export default {
- name: 'BoardList',
- components: {
- boardCard,
- boardNewIssue,
- GlLoadingIcon,
- },
- props: {
- disabled: {
- type: Boolean,
- required: true,
- },
- list: {
- type: Object,
- required: true,
- },
- issues: {
- type: Array,
- required: true,
- },
- },
- data() {
- return {
- scrollOffset: 250,
- filters: boardsStore.state.filters,
- showCount: false,
- showIssueForm: false,
- };
- },
- computed: {
- paginatedIssueText() {
- return sprintf(__('Showing %{pageSize} of %{total} issues'), {
- pageSize: this.list.issues.length,
- total: this.list.issuesSize,
- });
- },
- issuesSizeExceedsMax() {
- return this.list.maxIssueCount > 0 && this.list.issuesSize > this.list.maxIssueCount;
- },
- loading() {
- return this.list.loading;
- },
- },
- watch: {
- filters: {
- handler() {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.loadingMore = false;
- this.$refs.list.scrollTop = 0;
- },
- deep: true,
- },
- issues() {
- this.$nextTick(() => {
- if (
- this.scrollHeight() <= this.listHeight() &&
- this.list.issuesSize > this.list.issues.length &&
- this.list.isExpanded
- ) {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.page += 1;
- this.list.getIssues(false).catch(() => {
- // TODO: handle request error
- });
- }
-
- if (this.scrollHeight() > Math.ceil(this.listHeight())) {
- this.showCount = true;
- } else {
- this.showCount = false;
- }
- });
- },
- 'list.id': {
- handler(id) {
- if (id) {
- eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
- }
- },
- },
- },
- created() {
- eventHub.$on(`toggle-issue-form-${this.list.id}`, this.toggleForm);
- eventHub.$on(`scroll-board-list-${this.list.id}`, this.scrollToTop);
- },
- mounted() {
- const multiSelectOpts = {
- multiDrag: true,
- selectedClass: 'js-multi-select',
- animation: 500,
- };
-
- const options = getBoardSortableDefaultOptions({
- scroll: true,
- disabled: this.disabled,
- filter: '.board-list-count, .is-disabled',
- dataIdAttr: 'data-issue-id',
- removeCloneOnHide: false,
- ...multiSelectOpts,
- group: {
- name: 'issues',
- /**
- * Dynamically determine between which containers
- * items can be moved or copied as
- * Assignee lists (EE feature) require this behavior
- */
- pull: (to, from, dragEl, e) => {
- // As per Sortable's docs, `to` should provide
- // reference to exact sortable container on which
- // we're trying to drag element, but either it is
- // a library's bug or our markup structure is too complex
- // that `to` never points to correct container
- // See https://github.com/RubaXa/Sortable/issues/1037
- //
- // So we use `e.target` which is always accurate about
- // which element we're currently dragging our card upon
- // So from there, we can get reference to actual container
- // and thus the container type to enable Copy or Move
- if (e.target) {
- const containerEl =
- e.target.closest('.js-board-list') || e.target.querySelector('.js-board-list');
- const toBoardType = containerEl.dataset.boardType;
- const cloneActions = {
- label: ['milestone', 'assignee', 'iteration'],
- assignee: ['milestone', 'label', 'iteration'],
- milestone: ['label', 'assignee', 'iteration'],
- iteration: ['label', 'assignee', 'milestone'],
- };
-
- if (toBoardType) {
- const fromBoardType = this.list.type;
- // For each list we check if the destination list is
- // a the list were we should clone the issue
- const shouldClone = Object.entries(cloneActions).some(
- (entry) => fromBoardType === entry[0] && entry[1].includes(toBoardType),
- );
-
- if (shouldClone) {
- return 'clone';
- }
- }
- }
-
- return true;
- },
- revertClone: true,
- },
- onStart: (e) => {
- const card = this.$refs.issue[e.oldIndex];
-
- card.showDetail = false;
-
- const { list } = card;
-
- const issue = list.findIssue(Number(e.item.dataset.issueId));
-
- boardsStore.startMoving(list, issue);
-
- this.$root.$emit(BV_HIDE_TOOLTIP);
-
- sortableStart();
- },
- onAdd: (e) => {
- const { items = [], newIndicies = [] } = e;
- if (items.length) {
- // Not using e.newIndex here instead taking a min of all
- // the newIndicies. Basically we have to find that during
- // a drop what is the index we're going to start putting
- // all the dropped elements from.
- const newIndex = Math.min(...newIndicies.map((obj) => obj.index).filter((i) => i !== -1));
- const issues = items.map((item) =>
- boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
- );
-
- boardsStore.moveMultipleIssuesToList({
- listFrom: boardsStore.moving.list,
- listTo: this.list,
- issues,
- newIndex,
- });
- } else {
- boardsStore.moveIssueToList(
- boardsStore.moving.list,
- this.list,
- boardsStore.moving.issue,
- e.newIndex,
- );
- this.$nextTick(() => {
- e.item.remove();
- });
- }
- },
- onUpdate: (e) => {
- const sortedArray = this.sortable.toArray().filter((id) => id !== '-1');
-
- const { items = [], newIndicies = [], oldIndicies = [] } = e;
- if (items.length) {
- const newIndex = Math.min(...newIndicies.map((obj) => obj.index));
- const issues = items.map((item) =>
- boardsStore.moving.list.findIssue(Number(item.dataset.issueId)),
- );
- boardsStore.moveMultipleIssuesInList({
- list: this.list,
- issues,
- oldIndicies: oldIndicies.map((obj) => obj.index),
- newIndex,
- idArray: sortedArray,
- });
- e.items.forEach((el) => {
- Sortable.utils.deselect(el);
- });
- boardsStore.clearMultiSelect();
- return;
- }
-
- boardsStore.moveIssueInList(
- this.list,
- boardsStore.moving.issue,
- e.oldIndex,
- e.newIndex,
- sortedArray,
- );
- },
- onEnd: (e) => {
- const { items = [], clones = [], to } = e;
-
- // This is not a multi select operation
- if (!items.length && !clones.length) {
- sortableEnd();
- return;
- }
-
- let toList;
- if (to) {
- const containerEl = to.closest('.js-board-list');
- toList = boardsStore.findList('id', Number(containerEl.dataset.board));
- }
-
- /**
- * onEnd is called irrespective if the cards were moved in the
- * same list or the other list. Don't remove items if it's same list.
- */
- const isSameList = toList && toList.id === this.list.id;
- if (toList && !isSameList && boardsStore.shouldRemoveIssue(this.list, toList)) {
- const issues = items.map((item) => this.list.findIssue(Number(item.dataset.issueId)));
- if (
- issues.filter(Boolean).length &&
- !boardsStore.issuesAreContiguous(this.list, issues)
- ) {
- const indexes = [];
- const ids = this.list.issues.map((i) => i.id);
- issues.forEach((issue) => {
- const index = ids.indexOf(issue.id);
- if (index > -1) {
- indexes.push(index);
- }
- });
-
- // Descending sort because splice would cause index discrepancy otherwise
- const sortedIndexes = indexes.sort((a, b) => (a < b ? 1 : -1));
-
- sortedIndexes.forEach((i) => {
- /**
- * **setTimeout and splice each element one-by-one in a loop
- * is intended.**
- *
- * The problem here is all the indexes are in the list but are
- * non-contiguous. Due to that, when we splice all the indexes,
- * at once, Vue -- during a re-render -- is unable to find reference
- * nodes and the entire app crashes.
- *
- * If the indexes are contiguous, this piece of code is not
- * executed. If it is, this is a possible regression. Only when
- * issue indexes are far apart, this logic should ever kick in.
- */
- setTimeout(() => {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.issues.splice(i, 1);
- }, 0);
- });
- }
- }
-
- if (!toList) {
- createFlash({
- message: __('Something went wrong while performing the action.'),
- });
- }
-
- if (!isSameList) {
- boardsStore.clearMultiSelect();
-
- // Since Vue's list does not re-render the same keyed item, we'll
- // remove `multi-select` class to express it's unselected
- if (clones && clones.length) {
- clones.forEach((el) => el.classList.remove('multi-select'));
- }
-
- // Due to some bug which I am unable to figure out
- // Sortable does not deselect some pending items from the
- // source list.
- // We'll just do it forcefully here.
- Array.from(document.querySelectorAll('.js-multi-select') || []).forEach((item) => {
- Sortable.utils.deselect(item);
- });
-
- /**
- * SortableJS leaves all the moving items "as is" on the DOM.
- * Vue picks up and rehydrates the DOM, but we need to explicity
- * remove the "trash" items from the DOM.
- *
- * This is in parity to the logic on single item move from a list/in
- * a list. For reference, look at the implementation of onAdd method.
- */
- this.$nextTick(() => {
- if (items && items.length) {
- items.forEach((item) => {
- item.remove();
- });
- }
- });
- }
- sortableEnd();
- },
- onMove(e) {
- return !e.related.classList.contains('board-list-count');
- },
- onSelect(e) {
- const {
- item: { classList },
- } = e;
-
- if (
- classList &&
- classList.contains('js-multi-select') &&
- !classList.contains('multi-select')
- ) {
- Sortable.utils.deselect(e.item);
- }
- },
- onDeselect: (e) => {
- const {
- item: { dataset, classList },
- } = e;
-
- if (
- classList &&
- classList.contains('multi-select') &&
- !classList.contains('js-multi-select')
- ) {
- const issue = this.list.findIssue(Number(dataset.issueId));
- boardsStore.toggleMultiSelect(issue);
- }
- },
- });
-
- this.sortable = Sortable.create(this.$refs.list, options);
-
- // Scroll event on list to load more
- this.$refs.list.addEventListener('scroll', this.onScroll);
- },
- beforeDestroy() {
- eventHub.$off(`toggle-issue-form-${this.list.id}`, this.toggleForm);
- eventHub.$off(`scroll-board-list-${this.list.id}`, this.scrollToTop);
- this.$refs.list.removeEventListener('scroll', this.onScroll);
- },
- methods: {
- listHeight() {
- return this.$refs.list.getBoundingClientRect().height;
- },
- scrollHeight() {
- return this.$refs.list.scrollHeight;
- },
- scrollTop() {
- return this.$refs.list.scrollTop + this.listHeight();
- },
- scrollToTop() {
- this.$refs.list.scrollTop = 0;
- },
- loadNextPage() {
- const getIssues = this.list.nextPage();
- const loadingDone = () => {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.loadingMore = false;
- };
-
- if (getIssues) {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.loadingMore = true;
- getIssues.then(loadingDone).catch(loadingDone);
- }
- },
- toggleForm() {
- this.showIssueForm = !this.showIssueForm;
- },
- onScroll() {
- if (!this.list.loadingMore && this.scrollTop() > this.scrollHeight() - this.scrollOffset) {
- this.loadNextPage();
- }
- },
- },
-};
-</script>
-
-<template>
- <div
- :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
- class="board-list-component position-relative h-100"
- data-qa-selector="board_list_cards_area"
- >
- <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
- <gl-loading-icon size="sm" />
- </div>
- <board-new-issue v-if="list.type !== 'closed' && showIssueForm" :list="list" />
- <ul
- v-show="!loading"
- ref="list"
- :data-board="list.id"
- :data-board-type="list.type"
- :class="{ 'is-smaller': showIssueForm, 'bg-danger-100': issuesSizeExceedsMax }"
- class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
- >
- <board-card
- v-for="(issue, index) in issues"
- ref="issue"
- :key="issue.id"
- :index="index"
- :list="list"
- :issue="issue"
- :disabled="disabled"
- />
- <li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
- <gl-loading-icon v-show="list.loadingMore" size="sm" label="Loading more issues" />
- <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
- <span v-else>{{ paginatedIssueText }}</span>
- </li>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue
index 8d5f0f7eb89..dc5313b1bf6 100644
--- a/app/assets/javascripts/boards/components/board_list_header.vue
+++ b/app/assets/javascripts/boards/components/board_list_header.vue
@@ -201,7 +201,7 @@ export default {
});
},
addToLocalStorage() {
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (AccessorUtilities.canUseLocalStorage()) {
localStorage.setItem(`${this.uniqueKey}.collapsed`, this.list.collapsed);
}
},
diff --git a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue b/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
deleted file mode 100644
index bc29728fc55..00000000000
--- a/app/assets/javascripts/boards/components/board_list_header_deprecated.vue
+++ /dev/null
@@ -1,361 +0,0 @@
-<script>
-import {
- GlButton,
- GlButtonGroup,
- GlLabel,
- GlTooltip,
- GlIcon,
- GlSprintf,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { mapActions, mapState } from 'vuex';
-import { isScopedLabel } from '~/lib/utils/common_utils';
-import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
-import { n__, s__ } from '~/locale';
-import sidebarEventHub from '~/sidebar/event_hub';
-import AccessorUtilities from '../../lib/utils/accessor';
-import { inactiveId, LIST, ListType } from '../constants';
-import eventHub from '../eventhub';
-import boardsStore from '../stores/boards_store';
-import IssueCount from './item_count.vue';
-
-// This component is being replaced in favor of './board_list_header.vue' for GraphQL boards
-
-export default {
- components: {
- GlButtonGroup,
- GlButton,
- GlLabel,
- GlTooltip,
- GlIcon,
- GlSprintf,
- IssueCount,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- inject: {
- currentUserId: {
- default: null,
- },
- boardId: {
- default: '',
- },
- },
- props: {
- list: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- disabled: {
- type: Boolean,
- required: true,
- },
- isSwimlanesHeader: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- weightFeatureAvailable: false,
- };
- },
- computed: {
- ...mapState(['activeId']),
- isLoggedIn() {
- return Boolean(this.currentUserId);
- },
- listType() {
- return this.list.type;
- },
- listAssignee() {
- return this.list?.assignee?.username || '';
- },
- listTitle() {
- return this.list?.label?.description || this.list.title || '';
- },
- showListHeaderButton() {
- return !this.disabled && this.listType !== ListType.closed;
- },
- showMilestoneListDetails() {
- return this.list.type === 'milestone' && this.list.milestone && this.showListDetails;
- },
- showAssigneeListDetails() {
- return this.list.type === 'assignee' && this.showListDetails;
- },
- showIterationListDetails() {
- return this.listType === ListType.iteration && this.showListDetails;
- },
- showListDetails() {
- return this.list.isExpanded || !this.isSwimlanesHeader;
- },
- showListHeaderActions() {
- if (this.isLoggedIn) {
- return this.isNewIssueShown || this.isSettingsShown;
- }
- return false;
- },
- issuesCount() {
- return this.list.issuesSize;
- },
- issuesTooltipLabel() {
- return n__(`%d issue`, `%d issues`, this.issuesCount);
- },
- chevronTooltip() {
- return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
- },
- chevronIcon() {
- return this.list.isExpanded ? 'chevron-right' : 'chevron-down';
- },
- isNewIssueShown() {
- return this.listType === ListType.backlog || this.showListHeaderButton;
- },
- isSettingsShown() {
- return (
- this.listType !== ListType.backlog && this.showListHeaderButton && this.list.isExpanded
- );
- },
- uniqueKey() {
- // eslint-disable-next-line @gitlab/require-i18n-strings
- return `boards.${this.boardId}.${this.listType}.${this.list.id}`;
- },
- collapsedTooltipTitle() {
- return this.listTitle || this.listAssignee;
- },
- },
- methods: {
- ...mapActions(['setActiveId']),
- openSidebarSettings() {
- if (this.activeId === inactiveId) {
- sidebarEventHub.$emit('sidebar.closeAll');
- }
-
- this.setActiveId({ id: this.list.id, sidebarType: LIST });
- },
- showScopedLabels(label) {
- return boardsStore.scopedLabels.enabled && isScopedLabel(label);
- },
-
- showNewIssueForm() {
- eventHub.$emit(`toggle-issue-form-${this.list.id}`);
- },
- toggleExpanded() {
- // eslint-disable-next-line vue/no-mutating-props
- this.list.isExpanded = !this.list.isExpanded;
-
- if (!this.isLoggedIn) {
- this.addToLocalStorage();
- } else {
- this.updateListFunction();
- }
-
- // When expanding/collapsing, the tooltip on the caret button sometimes stays open.
- // Close all tooltips manually to prevent dangling tooltips.
- this.$root.$emit(BV_HIDE_TOOLTIP);
- },
- addToLocalStorage() {
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
- localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
- }
- },
- updateListFunction() {
- this.list.update();
- },
- },
-};
-</script>
-
-<template>
- <header
- :class="{
- 'has-border': list.label && list.label.color,
- 'gl-h-full': !list.isExpanded,
- 'board-inner gl-rounded-top-left-base gl-rounded-top-right-base': isSwimlanesHeader,
- }"
- :style="{ borderTopColor: list.label && list.label.color ? list.label.color : null }"
- class="board-header gl-relative"
- data-qa-selector="board_list_header"
- data-testid="board-list-header"
- >
- <h3
- :class="{
- 'user-can-drag': !disabled && !list.preset,
- 'gl-py-3 gl-h-full': !list.isExpanded && !isSwimlanesHeader,
- 'gl-border-b-0': !list.isExpanded || isSwimlanesHeader,
- 'gl-py-2': !list.isExpanded && isSwimlanesHeader,
- 'gl-flex-direction-column': !list.isExpanded,
- }"
- class="board-title gl-m-0 gl-display-flex gl-align-items-center gl-font-base gl-px-3 js-board-handle"
- >
- <gl-button
- v-if="list.isExpandable"
- v-gl-tooltip.hover
- :aria-label="chevronTooltip"
- :title="chevronTooltip"
- :icon="chevronIcon"
- class="board-title-caret no-drag gl-cursor-pointer"
- category="tertiary"
- size="small"
- @click="toggleExpanded"
- />
- <!-- The following is only true in EE and if it is a milestone -->
- <span
- v-if="showMilestoneListDetails"
- aria-hidden="true"
- class="milestone-icon"
- :class="{
- 'gl-mt-3 gl-rotate-90': !list.isExpanded,
- 'gl-mr-2': list.isExpanded,
- }"
- >
- <gl-icon name="timer" />
- </span>
-
- <span
- v-if="showIterationListDetails"
- aria-hidden="true"
- :class="{
- 'gl-mt-3 gl-rotate-90': !list.isExpanded,
- 'gl-mr-2': list.isExpanded,
- }"
- >
- <gl-icon name="iteration" />
- </span>
-
- <a
- v-if="showAssigneeListDetails"
- :href="list.assignee.path"
- class="user-avatar-link js-no-trigger"
- :class="{
- 'gl-mt-3 gl-rotate-90': !list.isExpanded,
- }"
- >
- <img
- v-gl-tooltip.hover.bottom
- :title="listAssignee"
- :alt="list.assignee.name"
- :src="list.assignee.avatar"
- class="avatar s20"
- height="20"
- width="20"
- />
- </a>
- <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,
- }"
- >
- <span
- v-if="list.type !== 'label'"
- v-gl-tooltip.hover
- :class="{
- 'gl-display-block': !list.isExpanded || list.type === 'milestone',
- }"
- :title="listTitle"
- class="board-title-main-text gl-text-truncate"
- >
- {{ list.title }}
- </span>
- <span
- v-if="list.type === 'assignee'"
- class="gl-ml-2 gl-font-weight-normal gl-text-gray-500"
- :class="{ 'gl-display-none': !list.isExpanded }"
- >
- @{{ listAssignee }}
- </span>
- <gl-label
- v-if="list.type === 'label'"
- v-gl-tooltip.hover.bottom
- :background-color="list.label.color"
- :description="list.label.description"
- :scoped="showScopedLabels(list.label)"
- :size="!list.isExpanded ? 'sm' : ''"
- :title="list.label.title"
- />
- </div>
-
- <span
- v-if="isSwimlanesHeader && !list.isExpanded"
- ref="collapsedInfo"
- aria-hidden="true"
- class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500"
- >
- <gl-icon name="information" />
- </span>
- <gl-tooltip v-if="isSwimlanesHeader && !list.isExpanded" :target="() => $refs.collapsedInfo">
- <div class="gl-font-weight-bold gl-pb-2">{{ collapsedTooltipTitle }}</div>
- <div v-if="list.maxIssueCount !== 0">
- &#8226;
- <gl-sprintf :message="__('%{issuesSize} with a limit of %{maxIssueCount}')">
- <template #issuesSize>{{ issuesTooltipLabel }}</template>
- <template #maxIssueCount>{{ list.maxIssueCount }}</template>
- </gl-sprintf>
- </div>
- <div v-else>&#8226; {{ issuesTooltipLabel }}</div>
- <div v-if="weightFeatureAvailable">
- &#8226;
- <gl-sprintf :message="__('%{totalWeight} total weight')">
- <template #totalWeight>{{ list.totalWeight }}</template>
- </gl-sprintf>
- </div>
- </gl-tooltip>
-
- <div
- class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary"
- :class="{
- 'gl-display-none!': !list.isExpanded && isSwimlanesHeader,
- 'gl-p-0': !list.isExpanded,
- }"
- >
- <span class="gl-display-inline-flex">
- <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" />
- <span ref="issueCount" class="issue-count-badge-count">
- <gl-icon class="gl-mr-2" name="issues" />
- <issue-count :items-size="issuesCount" :max-issue-count="list.maxIssueCount" />
- </span>
- <!-- The following is only true in EE. -->
- <template v-if="weightFeatureAvailable">
- <gl-tooltip :target="() => $refs.weightTooltip" :title="weightCountToolTip" />
- <span ref="weightTooltip" class="gl-display-inline-flex gl-ml-3">
- <gl-icon class="gl-mr-2" name="weight" />
- {{ list.totalWeight }}
- </span>
- </template>
- </span>
- </div>
- <gl-button-group v-if="showListHeaderActions" class="board-list-button-group pl-2">
- <gl-button
- v-if="isNewIssueShown"
- ref="newIssueBtn"
- v-gl-tooltip.hover
- :class="{
- 'gl-display-none': !list.isExpanded,
- }"
- :aria-label="__('New issue')"
- :title="__('New issue')"
- class="issue-count-badge-add-button no-drag"
- icon="plus"
- @click="showNewIssueForm"
- />
-
- <gl-button
- v-if="isSettingsShown"
- ref="settingsBtn"
- v-gl-tooltip.hover
- :aria-label="__('List settings')"
- class="no-drag js-board-settings-button"
- :title="__('List settings')"
- icon="settings"
- @click="openSidebarSettings"
- />
- <gl-tooltip :target="() => $refs.settingsBtn">{{ __('List settings') }}</gl-tooltip>
- </gl-button-group>
- </h3>
- </header>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue b/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
deleted file mode 100644
index a25b436b8de..00000000000
--- a/app/assets/javascripts/boards/components/board_new_issue_deprecated.vue
+++ /dev/null
@@ -1,138 +0,0 @@
-<script>
-import { GlButton } from '@gitlab/ui';
-import { mapGetters } from 'vuex';
-import { getMilestone } from 'ee_else_ce/boards/boards_util';
-import ListIssue from 'ee_else_ce/boards/models/issue';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import eventHub from '../eventhub';
-import boardsStore from '../stores/boards_store';
-import ProjectSelect from './project_select_deprecated.vue';
-
-// This component is being replaced in favor of './board_new_issue.vue' for GraphQL boards
-
-export default {
- name: 'BoardNewIssueDeprecated',
- components: {
- ProjectSelect,
- GlButton,
- },
- mixins: [glFeatureFlagMixin()],
- inject: ['groupId'],
- props: {
- list: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- title: '',
- error: false,
- selectedProject: {},
- };
- },
- computed: {
- ...mapGetters(['isGroupBoard']),
- disabled() {
- if (this.isGroupBoard) {
- return this.title === '' || !this.selectedProject.name;
- }
- return this.title === '';
- },
- },
- mounted() {
- this.$refs.input.focus();
- eventHub.$on('setSelectedProject', this.setSelectedProject);
- },
- methods: {
- submit(e) {
- e.preventDefault();
- if (this.title.trim() === '') return Promise.resolve();
-
- this.error = false;
-
- const labels = this.list.label ? [this.list.label] : [];
- const assignees = this.list.assignee ? [this.list.assignee] : [];
- const milestone = getMilestone(this.list);
-
- const { weightFeatureAvailable } = boardsStore;
- const { weight } = weightFeatureAvailable ? boardsStore.state.currentBoard : {};
-
- const issue = new ListIssue({
- title: this.title,
- labels,
- subscribed: true,
- assignees,
- milestone,
- project_id: this.selectedProject.id,
- weight,
- });
-
- eventHub.$emit(`scroll-board-list-${this.list.id}`);
- this.cancel();
-
- return this.list
- .newIssue(issue)
- .then(() => {
- boardsStore.setIssueDetail(issue);
- boardsStore.setListDetail(this.list);
- })
- .catch(() => {
- this.list.removeIssue(issue);
-
- // Show error message
- this.error = true;
- });
- },
- cancel() {
- this.title = '';
- eventHub.$emit(`toggle-issue-form-${this.list.id}`);
- },
- setSelectedProject(selectedProject) {
- this.selectedProject = selectedProject;
- },
- },
-};
-</script>
-
-<template>
- <div class="board-new-issue-form">
- <div class="board-card position-relative p-3 rounded">
- <form @submit="submit($event)">
- <div v-if="error" class="flash-container">
- <div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
- </div>
- <label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
- <input
- :id="list.id + '-title'"
- ref="input"
- v-model="title"
- class="form-control"
- type="text"
- name="issue_title"
- autocomplete="off"
- />
- <project-select v-if="isGroupBoard" :group-id="groupId" :list="list" />
- <div class="clearfix gl-mt-3">
- <gl-button
- ref="submitButton"
- :disabled="disabled"
- class="float-left js-no-auto-disable"
- variant="success"
- category="primary"
- type="submit"
- >{{ __('Create issue') }}</gl-button
- >
- <gl-button
- ref="cancelButton"
- class="float-right"
- type="button"
- variant="default"
- @click="cancel"
- >{{ __('Cancel') }}</gl-button
- >
- </div>
- </form>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
index c089a6a39af..6b7c08d05a5 100644
--- a/app/assets/javascripts/boards/components/board_settings_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue
@@ -3,7 +3,6 @@ import { GlButton, GlDrawer, GlLabel } from '@gitlab/ui';
import { MountingPortal } from 'portal-vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import { LIST, ListType, ListTypeTitles } from '~/boards/constants';
-import boardsStore from '~/boards/stores/boards_store';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
@@ -23,7 +22,7 @@ export default {
import('ee_component/boards/components/board_settings_list_types.vue'),
},
mixins: [glFeatureFlagMixin(), Tracking.mixin()],
- inject: ['canAdminList'],
+ inject: ['canAdminList', 'scopedLabelsAvailable'],
inheritAttrs: false,
data() {
return {
@@ -31,20 +30,13 @@ export default {
};
},
computed: {
- ...mapGetters(['isSidebarOpen', 'shouldUseGraphQL', 'isEpicBoard']),
+ ...mapGetters(['isSidebarOpen', 'isEpicBoard']),
...mapState(['activeId', 'sidebarType', 'boardLists']),
isWipLimitsOn() {
return this.glFeatures.wipLimits && !this.isEpicBoard;
},
activeList() {
- /*
- Warning: Though a computed property it is not reactive because we are
- referencing a List Model class. Reactivity only applies to plain JS objects
- */
- if (this.shouldUseGraphQL || this.isEpicBoard) {
- return this.boardLists[this.activeId];
- }
- return boardsStore.state.lists.find(({ id }) => id === this.activeId);
+ return this.boardLists[this.activeId] || {};
},
activeListLabel() {
return this.activeList.label;
@@ -68,17 +60,13 @@ export default {
methods: {
...mapActions(['unsetActiveId', 'removeList']),
showScopedLabels(label) {
- return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ return this.scopedLabelsAvailable && isScopedLabel(label);
},
deleteBoard() {
// eslint-disable-next-line no-alert
if (window.confirm(__('Are you sure you want to remove this list?'))) {
- if (this.shouldUseGraphQL || this.isEpicBoard) {
- this.track('click_button', { label: 'remove_list' });
- this.removeList(this.activeId);
- } else {
- this.activeList.destroy();
- }
+ this.track('click_button', { label: 'remove_list' });
+ this.removeList(this.activeId);
this.unsetActiveId();
}
},
@@ -93,9 +81,26 @@ export default {
v-bind="$attrs"
class="js-board-settings-sidebar gl-absolute"
:open="isSidebarOpen"
+ variant="sidebar"
@close="unsetActiveId"
>
- <template #title>{{ $options.listSettingsText }}</template>
+ <template #title>
+ <h2 class="gl-my-0 gl-font-size-h2 gl-line-height-24">
+ {{ $options.listSettingsText }}
+ </h2>
+ </template>
+ <template #header>
+ <div v-if="canAdminList && activeList.id" class="gl-mt-3">
+ <gl-button
+ variant="danger"
+ category="secondary"
+ size="small"
+ data-testid="remove-list"
+ @click.stop="deleteBoard"
+ >{{ __('Remove list') }}
+ </gl-button>
+ </div>
+ </template>
<template v-if="isSidebarOpen">
<div v-if="boardListType === ListType.label">
<label class="js-list-label gl-display-block">{{ listTypeTitle }}</label>
@@ -115,16 +120,6 @@ export default {
v-if="isWipLimitsOn"
:max-issue-count="activeList.maxIssueCount"
/>
- <div v-if="canAdminList && !activeList.preset && activeList.id" class="gl-mt-4">
- <gl-button
- variant="danger"
- category="secondary"
- icon="remove"
- data-testid="remove-list"
- @click.stop="deleteBoard"
- >{{ __('Remove list') }}
- </gl-button>
- </div>
</template>
</gl-drawer>
</mounting-portal>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
deleted file mode 100644
index 21a34182369..00000000000
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ /dev/null
@@ -1,115 +0,0 @@
-// This is a true violation of @gitlab/no-runtime-template-compiler, as it
-// relies on app/views/shared/boards/components/_sidebar.html.haml for its
-// template.
-/* eslint-disable no-new, @gitlab/no-runtime-template-compiler */
-
-import { GlLabel } from '@gitlab/ui';
-import $ from 'jquery';
-import Vue from 'vue';
-import DueDateSelectors from '~/due_date_select';
-import IssuableContext from '~/issuable_context';
-import LabelsSelect from '~/labels_select';
-import { isScopedLabel } from '~/lib/utils/common_utils';
-import { sprintf, __ } from '~/locale';
-import MilestoneSelect from '~/milestone_select';
-import Sidebar from '~/right_sidebar';
-import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
-import Assignees from '~/sidebar/components/assignees/assignees.vue';
-import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
-import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
-import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
-import eventHub from '~/sidebar/event_hub';
-import boardsStore from '../stores/boards_store';
-
-export default Vue.extend({
- components: {
- AssigneeTitle,
- Assignees,
- GlLabel,
- SidebarEpicsSelect: () =>
- import('ee_component/sidebar/components/sidebar_item_epics_select.vue'),
- Subscriptions,
- TimeTracker,
- SidebarAssigneesWidget,
- },
- props: {
- currentUser: {
- type: Object,
- default: () => ({}),
- required: false,
- },
- },
- data() {
- return {
- detail: boardsStore.detail,
- issue: {},
- list: {},
- loadingAssignees: false,
- timeTrackingLimitToHours: boardsStore.timeTracking.limitToHours,
- };
- },
- computed: {
- showSidebar() {
- return Object.keys(this.issue).length;
- },
- milestoneTitle() {
- return this.issue.milestone ? this.issue.milestone.title : __('No milestone');
- },
- canRemove() {
- return !this.list?.preset;
- },
- hasLabels() {
- return this.issue.labels && this.issue.labels.length;
- },
- labelDropdownTitle() {
- return this.hasLabels
- ? sprintf(__('%{firstLabel} +%{labelCount} more'), {
- firstLabel: this.issue.labels[0].title,
- labelCount: this.issue.labels.length - 1,
- })
- : __('Label');
- },
- selectedLabels() {
- return this.hasLabels ? this.issue.labels.map((l) => l.title).join(',') : '';
- },
- },
- watch: {
- detail: {
- handler() {
- if (this.issue.id !== this.detail.issue.id) {
- $('.js-issue-board-sidebar', this.$el).each((i, el) => {
- $(el).data('deprecatedJQueryDropdown').clearMenu();
- });
- }
-
- this.issue = this.detail.issue;
- this.list = this.detail.list;
- },
- deep: true,
- },
- },
- created() {
- eventHub.$on('sidebar.closeAll', this.closeSidebar);
- },
- beforeDestroy() {
- eventHub.$off('sidebar.closeAll', this.closeSidebar);
- },
- mounted() {
- new IssuableContext(this.currentUser);
- new MilestoneSelect();
- new DueDateSelectors();
- new LabelsSelect();
- new Sidebar();
- },
- methods: {
- closeSidebar() {
- this.detail.issue = {};
- },
- setAssignees({ assignees }) {
- boardsStore.detail.issue.setAssignees(assignees);
- },
- showScopedLabels(label) {
- return boardsStore.scopedLabels.enabled && isScopedLabel(label);
- },
- },
-});
diff --git a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue b/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
deleted file mode 100644
index c1536dff2c6..00000000000
--- a/app/assets/javascripts/boards/components/boards_selector_deprecated.vue
+++ /dev/null
@@ -1,360 +0,0 @@
-<script>
-import {
- GlLoadingIcon,
- GlSearchBoxByType,
- GlDropdown,
- GlDropdownDivider,
- GlDropdownSectionHeader,
- GlDropdownItem,
- GlModalDirective,
-} from '@gitlab/ui';
-import { throttle } from 'lodash';
-import { mapGetters, mapState } from 'vuex';
-
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import httpStatusCodes from '~/lib/utils/http_status';
-
-import groupQuery from '../graphql/group_boards.query.graphql';
-import projectQuery from '../graphql/project_boards.query.graphql';
-
-import boardsStore from '../stores/boards_store';
-import BoardForm from './board_form.vue';
-
-const MIN_BOARDS_TO_VIEW_RECENT = 10;
-
-export default {
- name: 'BoardsSelector',
- components: {
- BoardForm,
- GlLoadingIcon,
- GlSearchBoxByType,
- GlDropdown,
- GlDropdownDivider,
- GlDropdownSectionHeader,
- GlDropdownItem,
- },
- directives: {
- GlModalDirective,
- },
- props: {
- currentBoard: {
- type: Object,
- required: true,
- },
- throttleDuration: {
- type: Number,
- default: 200,
- required: false,
- },
- boardBaseUrl: {
- type: String,
- required: true,
- },
- hasMissingBoards: {
- type: Boolean,
- required: true,
- },
- canAdminBoard: {
- type: Boolean,
- required: true,
- },
- multipleIssueBoardsAvailable: {
- type: Boolean,
- required: true,
- },
- labelsPath: {
- type: String,
- required: true,
- },
- labelsWebUrl: {
- type: String,
- required: true,
- },
- projectId: {
- type: Number,
- required: true,
- },
- groupId: {
- type: Number,
- required: true,
- },
- scopedIssueBoardFeatureEnabled: {
- type: Boolean,
- required: true,
- },
- weights: {
- type: Array,
- required: true,
- },
- enabledScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- hasScrollFade: false,
- loadingBoards: 0,
- loadingRecentBoards: false,
- scrollFadeInitialized: false,
- boards: [],
- recentBoards: [],
- state: boardsStore.state,
- throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
- contentClientHeight: 0,
- maxPosition: 0,
- store: boardsStore,
- filterTerm: '',
- };
- },
- computed: {
- ...mapState(['boardType']),
- ...mapGetters(['isGroupBoard']),
- parentType() {
- return this.boardType;
- },
- loading() {
- return this.loadingRecentBoards || Boolean(this.loadingBoards);
- },
- currentPage() {
- return this.state.currentPage;
- },
- filteredBoards() {
- return this.boards.filter((board) =>
- board.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
- );
- },
- board() {
- return this.state.currentBoard;
- },
- showDelete() {
- return this.boards.length > 1;
- },
- scrollFadeClass() {
- return {
- 'fade-out': !this.hasScrollFade,
- };
- },
- showRecentSection() {
- return (
- this.recentBoards.length &&
- this.boards.length > MIN_BOARDS_TO_VIEW_RECENT &&
- !this.filterTerm.length
- );
- },
- },
- watch: {
- filteredBoards() {
- this.scrollFadeInitialized = false;
- this.$nextTick(this.setScrollFade);
- },
- },
- created() {
- boardsStore.setCurrentBoard(this.currentBoard);
- },
- methods: {
- showPage(page) {
- boardsStore.showPage(page);
- },
- cancel() {
- this.showPage('');
- },
- loadBoards(toggleDropdown = true) {
- if (toggleDropdown && this.boards.length > 0) {
- return;
- }
-
- this.$apollo.addSmartQuery('boards', {
- variables() {
- return { fullPath: this.state.endpoints.fullPath };
- },
- query() {
- return this.isGroupBoard ? groupQuery : projectQuery;
- },
- loadingKey: 'loadingBoards',
- update(data) {
- if (!data?.[this.parentType]) {
- return [];
- }
- return data[this.parentType].boards.edges.map(({ node }) => ({
- id: getIdFromGraphQLId(node.id),
- name: node.name,
- }));
- },
- });
-
- this.loadingRecentBoards = true;
- boardsStore
- .recentBoards()
- .then((res) => {
- this.recentBoards = res.data;
- })
- .catch((err) => {
- /**
- * If user is unauthorized we'd still want to resolve the
- * request to display all boards.
- */
- if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
- this.recentBoards = []; // recent boards are empty
- return;
- }
- throw err;
- })
- .then(() => this.$nextTick()) // Wait for boards list in DOM
- .then(() => {
- this.setScrollFade();
- })
- .catch(() => {})
- .finally(() => {
- this.loadingRecentBoards = false;
- });
- },
- isScrolledUp() {
- const { content } = this.$refs;
-
- if (!content) {
- return false;
- }
-
- const currentPosition = this.contentClientHeight + content.scrollTop;
-
- return currentPosition < this.maxPosition;
- },
- initScrollFade() {
- const { content } = this.$refs;
-
- if (!content) {
- return;
- }
-
- this.scrollFadeInitialized = true;
-
- this.contentClientHeight = content.clientHeight;
- this.maxPosition = content.scrollHeight;
- },
- setScrollFade() {
- if (!this.scrollFadeInitialized) this.initScrollFade();
-
- this.hasScrollFade = this.isScrolledUp();
- },
- },
-};
-</script>
-
-<template>
- <div class="boards-switcher js-boards-selector gl-mr-3">
- <span class="boards-selector-wrapper js-boards-selector-wrapper">
- <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"
- >
- <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"
- ref="content"
- data-qa-selector="boards_dropdown_content"
- class="dropdown-content flex-fill"
- @scroll.passive="throttledSetScrollFade"
- >
- <gl-dropdown-item
- v-show="filteredBoards.length === 0"
- class="gl-pointer-events-none text-secondary"
- >
- {{ s__('IssueBoards|No matching boards found') }}
- </gl-dropdown-item>
-
- <gl-dropdown-section-header v-if="showRecentSection">
- {{ __('Recent') }}
- </gl-dropdown-section-header>
-
- <template v-if="showRecentSection">
- <gl-dropdown-item
- v-for="recentBoard in recentBoards"
- :key="`recent-${recentBoard.id}`"
- class="js-dropdown-item"
- :href="`${boardBaseUrl}/${recentBoard.id}`"
- >
- {{ recentBoard.name }}
- </gl-dropdown-item>
- </template>
-
- <gl-dropdown-divider v-if="showRecentSection" />
-
- <gl-dropdown-section-header v-if="showRecentSection">
- {{ __('All') }}
- </gl-dropdown-section-header>
-
- <gl-dropdown-item
- v-for="otherBoard in filteredBoards"
- :key="otherBoard.id"
- class="js-dropdown-item"
- :href="`${boardBaseUrl}/${otherBoard.id}`"
- >
- {{ otherBoard.name }}
- </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-dropdown-item>
- </div>
-
- <div
- v-show="filteredBoards.length > 0"
- class="dropdown-content-faded-mask"
- :class="scrollFadeClass"
- ></div>
-
- <gl-loading-icon v-if="loading" size="sm" />
-
- <div v-if="canAdminBoard">
- <gl-dropdown-divider />
-
- <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-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-dropdown-item>
- </div>
- </gl-dropdown>
-
- <board-form
- v-if="currentPage"
- :labels-path="labelsPath"
- :labels-web-url="labelsWebUrl"
- :project-id="projectId"
- :group-id="groupId"
- :can-admin-board="canAdminBoard"
- :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
- :weights="weights"
- :enable-scoped-labels="enabledScopedLabels"
- :current-board="currentBoard"
- :current-page="state.currentPage"
- @cancel="cancel"
- />
- </span>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/config_toggle.vue b/app/assets/javascripts/boards/components/config_toggle.vue
index 30e304b8a65..f39e4d90357 100644
--- a/app/assets/javascripts/boards/components/config_toggle.vue
+++ b/app/assets/javascripts/boards/components/config_toggle.vue
@@ -15,11 +15,6 @@ export default {
},
mixins: [Tracking.mixin()],
props: {
- boardsStore: {
- type: Object,
- required: false,
- default: null,
- },
canAdminList: {
type: Boolean,
required: true,
@@ -41,9 +36,6 @@ export default {
showPage() {
this.track('click_button', { label: 'edit_board' });
eventHub.$emit('showBoardModal', formType.edit);
- if (this.boardsStore) {
- this.boardsStore.showPage(formType.edit);
- }
},
},
};
diff --git a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
index 5206db05410..b6c5ef955c6 100644
--- a/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
+++ b/app/assets/javascripts/boards/components/issue_board_filtered_search.vue
@@ -6,6 +6,7 @@ import issueBoardFilters from '~/boards/issue_board_filters';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { __ } from '~/locale';
+import { DEFAULT_MILESTONES_GRAPHQL } from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
@@ -63,17 +64,17 @@ export default {
return [
{
- icon: 'labels',
- title: label,
- type: 'label_name',
+ icon: 'user',
+ title: assignee,
+ type: 'assignee_username',
operators: [
{ value: '=', description: is },
{ value: '!=', description: isNot },
],
- token: LabelToken,
- unique: false,
- symbol: '~',
- fetchLabels,
+ token: AuthorToken,
+ unique: true,
+ fetchAuthors,
+ preloadedAuthors: this.preloadedAuthors(),
},
{
icon: 'pencil',
@@ -90,17 +91,27 @@ export default {
preloadedAuthors: this.preloadedAuthors(),
},
{
- icon: 'user',
- title: assignee,
- type: 'assignee_username',
+ icon: 'labels',
+ title: label,
+ type: 'label_name',
operators: [
{ value: '=', description: is },
{ value: '!=', description: isNot },
],
- token: AuthorToken,
+ token: LabelToken,
+ unique: false,
+ symbol: '~',
+ fetchLabels,
+ },
+ {
+ type: 'milestone_title',
+ title: milestone,
+ icon: 'clock',
+ symbol: '%',
+ token: MilestoneToken,
unique: true,
- fetchAuthors,
- preloadedAuthors: this.preloadedAuthors(),
+ defaultMilestones: DEFAULT_MILESTONES_GRAPHQL,
+ fetchMilestones: this.fetchMilestones,
},
{
icon: 'issues',
@@ -115,16 +126,6 @@ export default {
],
},
{
- type: 'milestone_title',
- title: milestone,
- icon: 'clock',
- symbol: '%',
- token: MilestoneToken,
- unique: true,
- defaultMilestones: [], // todo: https://gitlab.com/gitlab-org/gitlab/-/issues/337044#note_640010094
- fetchMilestones: this.fetchMilestones,
- },
- {
type: 'weight',
title: weight,
icon: 'weight',
diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
deleted file mode 100644
index 6e90731cc2f..00000000000
--- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue
+++ /dev/null
@@ -1,247 +0,0 @@
-<script>
-import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { sortBy } from 'lodash';
-import { mapState } from 'vuex';
-import boardCardInner from 'ee_else_ce/boards/mixins/board_card_inner';
-import { isScopedLabel } from '~/lib/utils/common_utils';
-import { sprintf, __, n__ } from '~/locale';
-import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-import boardsStore from '../stores/boards_store';
-import IssueDueDate from './issue_due_date.vue';
-import IssueTimeEstimate from './issue_time_estimate_deprecated.vue';
-
-export default {
- components: {
- GlLabel,
- GlIcon,
- UserAvatarLink,
- TooltipOnTruncate,
- IssueDueDate,
- IssueTimeEstimate,
- IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [boardCardInner],
- inject: ['groupId', 'rootPath'],
- props: {
- issue: {
- type: Object,
- required: true,
- },
- list: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- updateFilters: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- limitBeforeCounter: 2,
- maxRender: 3,
- maxCounter: 99,
- };
- },
- computed: {
- ...mapState(['isShowingLabels']),
- numberOverLimit() {
- return this.issue.assignees.length - this.limitBeforeCounter;
- },
- assigneeCounterTooltip() {
- const { numberOverLimit, maxCounter } = this;
- const count = numberOverLimit > maxCounter ? maxCounter : numberOverLimit;
- return sprintf(__('%{count} more assignees'), { count });
- },
- assigneeCounterLabel() {
- if (this.numberOverLimit > this.maxCounter) {
- return `${this.maxCounter}+`;
- }
-
- return `+${this.numberOverLimit}`;
- },
- shouldRenderCounter() {
- if (this.issue.assignees.length <= this.maxRender) {
- return false;
- }
-
- return this.issue.assignees.length > this.numberOverLimit;
- },
- issueId() {
- if (this.issue.iid) {
- return `#${this.issue.iid}`;
- }
- return false;
- },
- showLabelFooter() {
- return this.isShowingLabels && this.issue.labels.find(this.showLabel);
- },
- issueReferencePath() {
- const { referencePath, groupId } = this.issue;
- return !groupId ? referencePath.split('#')[0] : null;
- },
- orderedLabels() {
- return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
- },
- blockedLabel() {
- if (this.issue.blockedByCount) {
- return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount);
- }
- return __('Blocked issue');
- },
- assignees() {
- return this.issue.assignees.filter((_, index) => this.shouldRenderAssignee(index));
- },
- },
- methods: {
- isIndexLessThanlimit(index) {
- return index < this.limitBeforeCounter;
- },
- shouldRenderAssignee(index) {
- // Eg. maxRender is 4,
- // Render up to all 4 assignees if there are only 4 assigness
- // Otherwise render up to the limitBeforeCounter
- if (this.issue.assignees.length <= this.maxRender) {
- return index < this.maxRender;
- }
-
- return index < this.limitBeforeCounter;
- },
- assigneeUrl(assignee) {
- if (!assignee) return '';
- return `${this.rootPath}${assignee.username}`;
- },
- avatarUrlTitle(assignee) {
- return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name });
- },
- showLabel(label) {
- if (!label.id) return false;
- return true;
- },
- isNonListLabel(label) {
- return label.id && !(this.list.type === 'label' && this.list.title === label.title);
- },
- filterByLabel(label) {
- if (!this.updateFilters) return;
- const labelTitle = encodeURIComponent(label.title);
- const filter = `label_name[]=${labelTitle}`;
-
- boardsStore.toggleFilter(filter);
- },
- showScopedLabel(label) {
- return boardsStore.scopedLabels.enabled && isScopedLabel(label);
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="gl-display-flex" dir="auto">
- <h4 class="board-card-title gl-mb-0 gl-mt-0">
- <gl-icon
- v-if="issue.blocked"
- v-gl-tooltip
- name="issue-block"
- :title="blockedLabel"
- class="issue-blocked-icon gl-mr-2"
- :aria-label="blockedLabel"
- data-testid="issue-blocked-icon"
- />
- <gl-icon
- v-if="issue.confidential"
- v-gl-tooltip
- name="eye-slash"
- :title="__('Confidential')"
- class="confidential-icon gl-mr-2"
- :aria-label="__('Confidential')"
- />
- <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">
- <template v-for="label in orderedLabels">
- <gl-label
- :key="label.id"
- :background-color="label.color"
- :title="label.title"
- :description="label.description"
- size="sm"
- :scoped="showScopedLabel(label)"
- @click="filterByLabel(label)"
- />
- </template>
- </div>
- <div
- class="board-card-footer gl-display-flex gl-justify-content-space-between gl-align-items-flex-end"
- >
- <div
- class="gl-display-flex align-items-start flex-wrap-reverse board-card-number-container gl-overflow-hidden js-board-card-number-container"
- >
- <span
- v-if="issue.referencePath"
- class="board-card-number gl-overflow-hidden gl-display-flex gl-mr-3 gl-mt-3"
- >
- <tooltip-on-truncate
- v-if="issueReferencePath"
- :title="issueReferencePath"
- placement="bottom"
- class="board-issue-path gl-text-truncate gl-font-weight-bold"
- >{{ issueReferencePath }}</tooltip-on-truncate
- >
- #{{ 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 || Boolean(issue.closedAt)"
- />
- <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
- <issue-card-weight
- v-if="validIssueWeight(issue)"
- :weight="issue.weight"
- @click="filterByWeight(issue.weight)"
- />
- </span>
- </div>
- <div class="board-card-assignee gl-display-flex">
- <user-avatar-link
- v-for="assignee in assignees"
- :key="assignee.id"
- :link-href="assigneeUrl(assignee)"
- :img-alt="avatarUrlTitle(assignee)"
- :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url"
- :img-size="24"
- class="js-no-trigger"
- tooltip-placement="bottom"
- >
- <span class="js-assignee-tooltip">
- <span class="gl-font-weight-bold gl-display-block">{{ __('Assignee') }}</span>
- {{ assignee.name }}
- <span class="text-white-50">@{{ assignee.username }}</span>
- </span>
- </user-avatar-link>
- <span
- v-if="shouldRenderCounter"
- v-gl-tooltip
- :title="assigneeCounterTooltip"
- class="avatar-counter"
- data-placement="bottom"
- >{{ assigneeCounterLabel }}</span
- >
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue b/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue
deleted file mode 100644
index 8ddf50cb357..00000000000
--- a/app/assets/javascripts/boards/components/issue_time_estimate_deprecated.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-import { GlTooltip, GlIcon } from '@gitlab/ui';
-import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
-import boardsStore from '../stores/boards_store';
-
-export default {
- components: {
- GlIcon,
- GlTooltip,
- },
- props: {
- estimate: {
- type: [Number, String],
- required: true,
- },
- },
- data() {
- return {
- limitToHours: boardsStore.timeTracking.limitToHours,
- };
- },
- computed: {
- title() {
- return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true);
- },
- timeEstimate() {
- return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }));
- },
- },
-};
-</script>
-
-<template>
- <span>
- <span ref="issueTimeEstimate" class="board-card-info card-number">
- <gl-icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{
- timeEstimate
- }}</time>
- </span>
- <gl-tooltip
- :target="() => $refs.issueTimeEstimate"
- placement="bottom"
- class="js-issue-time-estimate"
- >
- <span class="bold d-block">{{ __('Time estimate') }}</span> {{ title }}
- </gl-tooltip>
- </span>
-</template>
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
deleted file mode 100644
index 6eb1dbfb46a..00000000000
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ /dev/null
@@ -1,119 +0,0 @@
-/* eslint-disable func-names, no-new */
-
-import $ from 'jquery';
-import store from '~/boards/stores';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import CreateLabelDropdown from '../../create_label';
-import { fullLabelId } from '../boards_util';
-import boardsStore from '../stores/boards_store';
-
-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) => {
- if (!addNewList) {
- return;
- }
-
- if (shouldCreateListGraphQL(label)) {
- store.dispatch('createList', { labelId: fullLabelId(label) });
- } else {
- boardsStore.new({
- title: label.title,
- position: boardsStore.state.lists.length - 2,
- list_type: 'label',
- label: {
- id: label.id,
- title: label.title,
- color: label.color,
- },
- });
- }
- });
-
-export default function initNewListDropdown() {
- $('.js-new-board-list').each(function () {
- const $dropdownToggle = $(this);
- const $dropdown = $dropdownToggle.closest('.dropdown');
- new CreateLabelDropdown(
- $dropdown.find('.dropdown-new-label'),
- $dropdownToggle.data('namespacePath'),
- $dropdownToggle.data('projectPath'),
- );
-
- initDeprecatedJQueryDropdown($dropdownToggle, {
- data(term, callback) {
- const reqFailed = () => {
- $dropdownToggle.data('bs.dropdown').hide();
- createFlash({
- message: __('Error fetching labels.'),
- });
- };
-
- if (store.getters.shouldUseGraphQL) {
- store
- .dispatch('fetchLabels')
- .then((data) => callback(data))
- .catch(reqFailed);
- } else {
- axios
- .get($dropdownToggle.attr('data-list-labels-path'))
- .then(({ data }) => callback(data))
- .catch(reqFailed);
- }
- },
- renderRow(label) {
- const active = store.getters.shouldUseGraphQL
- ? store.getters.getListByLabelId(label.id)
- : boardsStore.findListByLabelId(label.id);
- const $li = $('<li />');
- const $a = $('<a />', {
- class: active ? `is-active js-board-list-${getIdFromGraphQLId(active.id)}` : '',
- text: label.title,
- href: '#',
- });
- const $labelColor = $('<span />', {
- class: 'dropdown-label-box',
- style: `background-color: ${label.color}`,
- });
-
- return $li.append($a.prepend($labelColor));
- },
- search: {
- fields: ['title'],
- },
- filterable: true,
- selectable: true,
- multiSelect: true,
- containerSelector: '.js-tab-container-labels .dropdown-page-one .dropdown-content',
- clicked(options) {
- const { e } = options;
- const label = options.selectedObj;
- e.preventDefault();
-
- if (shouldCreateListGraphQL(label)) {
- store.dispatch('createList', { labelId: label.id });
- } else if (!boardsStore.findListByLabelId(label.id)) {
- boardsStore.new({
- title: label.title,
- position: boardsStore.state.lists.length - 2,
- list_type: 'label',
- label: {
- id: label.id,
- title: label.title,
- color: label.color,
- },
- });
- }
- },
- });
- });
-}
diff --git a/app/assets/javascripts/boards/components/project_select_deprecated.vue b/app/assets/javascripts/boards/components/project_select_deprecated.vue
deleted file mode 100644
index fc95ba0461d..00000000000
--- a/app/assets/javascripts/boards/components/project_select_deprecated.vue
+++ /dev/null
@@ -1,146 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
- GlLoadingIcon,
-} from '@gitlab/ui';
-import { s__ } from '~/locale';
-import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
-import Api from '../../api';
-import { ListType } from '../constants';
-import eventHub from '../eventhub';
-
-export default {
- name: 'ProjectSelect',
- i18n: {
- headerTitle: s__(`BoardNewIssue|Projects`),
- dropdownText: s__(`BoardNewIssue|Select a project`),
- searchPlaceholder: s__(`BoardNewIssue|Search projects`),
- emptySearchResult: s__(`BoardNewIssue|No matching results`),
- },
- defaultFetchOptions: {
- with_issues_enabled: true,
- with_shared: false,
- include_subgroups: true,
- order_by: 'similarity',
- archived: false,
- },
- components: {
- GlLoadingIcon,
- GlDropdown,
- GlDropdownItem,
- GlDropdownText,
- GlSearchBoxByType,
- },
- inject: ['groupId'],
- props: {
- list: {
- type: Object,
- required: true,
- },
- },
- data() {
- return {
- initialLoading: true,
- isFetching: false,
- projects: [],
- selectedProject: {},
- searchTerm: '',
- };
- },
- computed: {
- selectedProjectName() {
- return this.selectedProject.name || this.$options.i18n.dropdownText;
- },
- fetchOptions() {
- const additionalAttrs = {};
- if (this.list.type && this.list.type !== ListType.backlog) {
- additionalAttrs.min_access_level = featureAccessLevel.EVERYONE;
- }
-
- return {
- ...this.$options.defaultFetchOptions,
- ...additionalAttrs,
- };
- },
- isFetchResultEmpty() {
- return this.projects.length === 0;
- },
- },
- watch: {
- searchTerm() {
- this.fetchProjects();
- },
- },
- async mounted() {
- await this.fetchProjects();
-
- this.initialLoading = false;
- },
- methods: {
- async fetchProjects() {
- this.isFetching = true;
- try {
- const projects = await Api.groupProjects(this.groupId, this.searchTerm, this.fetchOptions);
-
- this.projects = projects.map((project) => {
- return {
- id: project.id,
- name: project.name,
- namespacedName: project.name_with_namespace,
- path: project.path_with_namespace,
- };
- });
- } catch (err) {
- /* Handled in Api.groupProjects */
- } finally {
- this.isFetching = false;
- }
- },
- selectProject(projectId) {
- this.selectedProject = this.projects.find((project) => project.id === projectId);
-
- eventHub.$emit('setSelectedProject', this.selectedProject);
- },
- },
-};
-</script>
-
-<template>
- <div>
- <label class="gl-font-weight-bold gl-mt-3" data-testid="header-label">{{
- $options.i18n.headerTitle
- }}</label>
- <gl-dropdown
- data-testid="project-select-dropdown"
- :text="selectedProjectName"
- :header-text="$options.i18n.headerTitle"
- block
- menu-class="gl-w-full!"
- :loading="initialLoading"
- >
- <gl-search-box-by-type
- v-model.trim="searchTerm"
- debounce="250"
- :placeholder="$options.i18n.searchPlaceholder"
- />
- <gl-dropdown-item
- v-for="project in projects"
- v-show="!isFetching"
- :key="project.id"
- :name="project.name"
- @click="selectProject(project.id)"
- >
- {{ project.namespacedName }}
- </gl-dropdown-item>
- <gl-dropdown-text v-show="isFetching" data-testid="dropdown-text-loading-icon">
- <gl-loading-icon class="gl-mx-auto" size="sm" />
- </gl-dropdown-text>
- <gl-dropdown-text v-if="isFetchResultEmpty && !isFetching" data-testid="empty-result-message">
- <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
- </gl-dropdown-text>
- </gl-dropdown>
- </div>
-</template>
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
index 41938d8e284..945a508c55d 100644
--- a/app/assets/javascripts/boards/config_toggle.js
+++ b/app/assets/javascripts/boards/config_toggle.js
@@ -2,7 +2,7 @@ import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import ConfigToggle from './components/config_toggle.vue';
-export default (boardsStore = undefined) => {
+export default () => {
const el = document.querySelector('.js-board-config');
if (!el) {
@@ -15,7 +15,6 @@ export default (boardsStore = undefined) => {
render(h) {
return h(ConfigToggle, {
props: {
- boardsStore,
canAdminList: parseBoolean(el.dataset.canAdminList),
hasScope: parseBoolean(el.dataset.hasScope),
},
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index 16fb4596726..391e0d1fb0a 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -119,6 +119,11 @@ export const DraggableItemTypes = {
list: 'list',
};
+export const MilestoneIDs = {
+ NONE: 0,
+ ANY: -1,
+};
+
export default {
BoardType,
ListType,
diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js
deleted file mode 100644
index 62a0d930ec0..00000000000
--- a/app/assets/javascripts/boards/ee_functions.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const setWeightFetchingState = () => {};
-export const setEpicFetchingState = () => {};
-
-export const getMilestoneTitle = () => ({});
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index c6040f1e4aa..72586970008 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -4,7 +4,6 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable
import { updateHistory } from '~/lib/utils/url_utility';
import FilteredSearchContainer from '../filtered_search/container';
import vuexstore from './stores';
-import boardsStore from './stores/boards_store';
export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
@@ -26,7 +25,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
this.cantEdit = cantEdit.filter((i) => typeof i === 'string');
this.cantEditWithValue = cantEdit.filter((i) => typeof i === 'object');
- if (vuexstore.getters.shouldUseGraphQL && vuexstore.state.boardConfig) {
+ if (vuexstore.state.boardConfig) {
const boardConfigPath = transformBoardConfig(vuexstore.state.boardConfig);
// TODO Refactor: https://gitlab.com/gitlab-org/gitlab/-/issues/329274
// here we are using "window.location.search" as a temporary store
@@ -45,14 +44,10 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
const groupByParam = new URLSearchParams(window.location.search).get('group_by');
this.store.path = `${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`;
- if (vuexstore.getters.shouldUseGraphQL) {
- updateHistory({
- url: `?${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`,
- });
- vuexstore.dispatch('performSearch');
- } else if (this.updateUrl) {
- boardsStore.updateFiltersUrl();
- }
+ updateHistory({
+ url: `?${path.substr(1)}${groupByParam ? `&group_by=${groupByParam}` : ''}`,
+ });
+ vuexstore.dispatch('performSearch');
}
removeTokens() {
diff --git a/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql b/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql
new file mode 100644
index 00000000000..1c382c4747b
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/group_board_iterations.query.graphql
@@ -0,0 +1,10 @@
+query GroupBoardIterations($fullPath: ID!, $title: String) {
+ group(fullPath: $fullPath) {
+ iterations(includeAncestors: true, title: $title) {
+ nodes {
+ id
+ title
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/issue.fragment.graphql b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
index 0ff70703e1a..1b14396fb5c 100644
--- a/app/assets/javascripts/boards/graphql/issue.fragment.graphql
+++ b/app/assets/javascripts/boards/graphql/issue.fragment.graphql
@@ -12,6 +12,7 @@ fragment IssueNode on Issue {
humanTotalTimeSpent
emailsDisabled
confidential
+ hidden
webUrl
relativePosition
assignees {
diff --git a/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql b/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql
new file mode 100644
index 00000000000..078151a275a
--- /dev/null
+++ b/app/assets/javascripts/boards/graphql/project_board_iterations.query.graphql
@@ -0,0 +1,10 @@
+query ProjectBoardIterations($fullPath: ID!, $title: String) {
+ project(fullPath: $fullPath) {
+ iterations(includeAncestors: true, title: $title) {
+ nodes {
+ id
+ title
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
index 776530ebb83..724b7f5a34c 100644
--- a/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
+++ b/app/assets/javascripts/boards/graphql/project_milestones.query.graphql
@@ -1,4 +1,4 @@
-query groupMilestones(
+query projectMilestones(
$fullPath: ID!
$state: MilestoneStateEnum
$includeAncestors: Boolean
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index de7c8a3bd6b..21c1bb23dc6 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -2,41 +2,20 @@ import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import PortalVue from 'portal-vue';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { mapActions, mapGetters } from 'vuex';
-import 'ee_else_ce/boards/models/issue';
-import 'ee_else_ce/boards/models/list';
-import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
-import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
-import {
- setWeightFetchingState,
- setEpicFetchingState,
- getMilestoneTitle,
-} from 'ee_else_ce/boards/ee_functions';
import toggleEpicsSwimlanes from 'ee_else_ce/boards/toggle_epics_swimlanes';
import toggleLabels from 'ee_else_ce/boards/toggle_labels';
import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
-import BoardContent from '~/boards/components/board_content.vue';
-import './models/label';
-import './models/assignee';
-import '~/boards/models/milestone';
-import '~/boards/models/project';
+import BoardApp from '~/boards/components/board_app.vue';
import '~/boards/filters/due_date_filters';
import { issuableTypes } from '~/boards/constants';
import eventHub from '~/boards/eventhub';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
import initBoardsFilteredSearch from '~/boards/mount_filtered_search_issue_boards';
import store from '~/boards/stores';
-import boardsStore from '~/boards/stores/boards_store';
import toggleFocusMode from '~/boards/toggle_focus';
import createDefaultClient from '~/lib/graphql';
-import {
- NavigationType,
- convertObjectPropsToCamelCase,
- parseBoolean,
-} from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
-import sidebarEventHub from '~/sidebar/event_hub';
+import { NavigationType, parseBoolean } from '~/lib/utils/common_utils';
import introspectionQueryResultData from '~/sidebar/fragmentTypes.json';
import { fullBoardId } from './boards_util';
import boardConfigToggle from './config_toggle';
@@ -61,10 +40,75 @@ const apolloProvider = new VueApollo({
),
});
-let issueBoardsApp;
+function mountBoardApp(el) {
+ const { boardId, groupId, fullPath, rootPath } = el.dataset;
+
+ store.dispatch('setInitialBoardData', {
+ boardId,
+ fullBoardId: fullBoardId(boardId),
+ fullPath,
+ boardType: el.dataset.parent,
+ disabled: parseBoolean(el.dataset.disabled) || true,
+ issuableType: issuableTypes.issue,
+ boardConfig: {
+ milestoneId: parseInt(el.dataset.boardMilestoneId, 10),
+ milestoneTitle: el.dataset.boardMilestoneTitle || '',
+ iterationId: parseInt(el.dataset.boardIterationId, 10),
+ iterationTitle: el.dataset.boardIterationTitle || '',
+ assigneeId: el.dataset.boardAssigneeId,
+ assigneeUsername: el.dataset.boardAssigneeUsername,
+ labels: el.dataset.labels ? JSON.parse(el.dataset.labels) : [],
+ labelIds: el.dataset.labelIds ? JSON.parse(el.dataset.labelIds) : [],
+ weight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null,
+ },
+ });
+
+ if (!gon?.features?.issueBoardsFilteredSearch) {
+ // Warning: FilteredSearchBoards has an implicit dependency on the Vuex state 'boardConfig'
+ // Improve this situation in the future.
+ const filterManager = new FilteredSearchBoards({ path: '' }, true, []);
+ filterManager.setup();
+
+ eventHub.$on('updateTokens', () => {
+ filterManager.updateTokens();
+ });
+ }
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ store,
+ apolloProvider,
+ provide: {
+ disabled: parseBoolean(el.dataset.disabled),
+ boardId,
+ groupId: Number(groupId),
+ rootPath,
+ currentUserId: gon.current_user_id || null,
+ canUpdate: parseBoolean(el.dataset.canUpdate),
+ canAdminList: parseBoolean(el.dataset.canAdminList),
+ labelsManagePath: el.dataset.labelsManagePath,
+ labelsFilterBasePath: el.dataset.labelsFilterBasePath,
+ timeTrackingLimitToHours: parseBoolean(el.dataset.timeTrackingLimitToHours),
+ multipleAssigneesFeatureAvailable: parseBoolean(el.dataset.multipleAssigneesFeatureAvailable),
+ epicFeatureAvailable: parseBoolean(el.dataset.epicFeatureAvailable),
+ iterationFeatureAvailable: parseBoolean(el.dataset.iterationFeatureAvailable),
+ weightFeatureAvailable: parseBoolean(el.dataset.weightFeatureAvailable),
+ boardWeight: el.dataset.boardWeight ? parseInt(el.dataset.boardWeight, 10) : null,
+ scopedLabelsAvailable: parseBoolean(el.dataset.scopedLabels),
+ milestoneListsAvailable: parseBoolean(el.dataset.milestoneListsAvailable),
+ assigneeListsAvailable: parseBoolean(el.dataset.assigneeListsAvailable),
+ iterationListsAvailable: parseBoolean(el.dataset.iterationListsAvailable),
+ issuableType: issuableTypes.issue,
+ emailsDisabled: parseBoolean(el.dataset.emailsDisabled),
+ },
+ render: (createComponent) => createComponent(BoardApp),
+ });
+}
export default () => {
- const $boardApp = document.getElementById('board-app');
+ const $boardApp = document.getElementById('js-issuable-board-app');
+
// check for browser back and trigger a hard reload to circumvent browser caching.
window.addEventListener('pageshow', (event) => {
const isNavTypeBackForward =
@@ -75,257 +119,11 @@ export default () => {
}
});
- if (issueBoardsApp) {
- issueBoardsApp.$destroy(true);
- }
-
if (gon?.features?.issueBoardsFilteredSearch) {
initBoardsFilteredSearch(apolloProvider);
}
- if (!gon?.features?.graphqlBoardLists) {
- boardsStore.create();
- boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
- }
-
- // eslint-disable-next-line @gitlab/no-runtime-template-compiler
- issueBoardsApp = new Vue({
- el: $boardApp,
- components: {
- BoardContent,
- BoardSidebar,
- BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'),
- },
- provide: {
- boardId: $boardApp.dataset.boardId,
- groupId: Number($boardApp.dataset.groupId),
- rootPath: $boardApp.dataset.rootPath,
- currentUserId: gon.current_user_id || null,
- canUpdate: parseBoolean($boardApp.dataset.canUpdate),
- canAdminList: parseBoolean($boardApp.dataset.canAdminList),
- labelsManagePath: $boardApp.dataset.labelsManagePath,
- labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,
- timeTrackingLimitToHours: parseBoolean($boardApp.dataset.timeTrackingLimitToHours),
- multipleAssigneesFeatureAvailable: parseBoolean(
- $boardApp.dataset.multipleAssigneesFeatureAvailable,
- ),
- epicFeatureAvailable: parseBoolean($boardApp.dataset.epicFeatureAvailable),
- iterationFeatureAvailable: parseBoolean($boardApp.dataset.iterationFeatureAvailable),
- weightFeatureAvailable: parseBoolean($boardApp.dataset.weightFeatureAvailable),
- boardWeight: $boardApp.dataset.boardWeight
- ? parseInt($boardApp.dataset.boardWeight, 10)
- : null,
- scopedLabelsAvailable: parseBoolean($boardApp.dataset.scopedLabels),
- milestoneListsAvailable: parseBoolean($boardApp.dataset.milestoneListsAvailable),
- assigneeListsAvailable: parseBoolean($boardApp.dataset.assigneeListsAvailable),
- iterationListsAvailable: parseBoolean($boardApp.dataset.iterationListsAvailable),
- issuableType: issuableTypes.issue,
- emailsDisabled: parseBoolean($boardApp.dataset.emailsDisabled),
- },
- store,
- apolloProvider,
- data() {
- return {
- state: boardsStore.state,
- loading: 0,
- boardsEndpoint: $boardApp.dataset.boardsEndpoint,
- recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
- listsEndpoint: $boardApp.dataset.listsEndpoint,
- disabled: parseBoolean($boardApp.dataset.disabled),
- bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
- detailIssue: boardsStore.detail,
- parent: $boardApp.dataset.parent,
- };
- },
- computed: {
- ...mapGetters(['shouldUseGraphQL']),
- detailIssueVisible() {
- return Object.keys(this.detailIssue.issue).length;
- },
- },
- created() {
- this.setInitialBoardData({
- boardId: $boardApp.dataset.boardId,
- fullBoardId: fullBoardId($boardApp.dataset.boardId),
- fullPath: $boardApp.dataset.fullPath,
- boardType: this.parent,
- disabled: this.disabled,
- issuableType: issuableTypes.issue,
- boardConfig: {
- milestoneId: parseInt($boardApp.dataset.boardMilestoneId, 10),
- milestoneTitle: $boardApp.dataset.boardMilestoneTitle || '',
- iterationId: parseInt($boardApp.dataset.boardIterationId, 10),
- iterationTitle: $boardApp.dataset.boardIterationTitle || '',
- assigneeId: $boardApp.dataset.boardAssigneeId,
- assigneeUsername: $boardApp.dataset.boardAssigneeUsername,
- labels: $boardApp.dataset.labels ? JSON.parse($boardApp.dataset.labels) : [],
- labelIds: $boardApp.dataset.labelIds ? JSON.parse($boardApp.dataset.labelIds) : [],
- weight: $boardApp.dataset.boardWeight
- ? parseInt($boardApp.dataset.boardWeight, 10)
- : null,
- },
- });
- boardsStore.setEndpoints({
- boardsEndpoint: this.boardsEndpoint,
- recentBoardsEndpoint: this.recentBoardsEndpoint,
- listsEndpoint: this.listsEndpoint,
- bulkUpdatePath: this.bulkUpdatePath,
- boardId: $boardApp.dataset.boardId,
- fullPath: $boardApp.dataset.fullPath,
- });
- boardsStore.rootPath = this.boardsEndpoint;
-
- eventHub.$on('updateTokens', this.updateTokens);
- eventHub.$on('newDetailIssue', this.updateDetailIssue);
- eventHub.$on('clearDetailIssue', this.clearDetailIssue);
- sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
- eventHub.$on('initialBoardLoad', this.initialBoardLoad);
- },
- beforeDestroy() {
- eventHub.$off('updateTokens', this.updateTokens);
- eventHub.$off('newDetailIssue', this.updateDetailIssue);
- eventHub.$off('clearDetailIssue', this.clearDetailIssue);
- sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
- eventHub.$off('initialBoardLoad', this.initialBoardLoad);
- },
- mounted() {
- if (!gon?.features?.issueBoardsFilteredSearch) {
- this.filterManager = new FilteredSearchBoards(
- boardsStore.filter,
- true,
- boardsStore.cantEdit,
- );
- this.filterManager.setup();
- }
-
- this.performSearch();
-
- boardsStore.disabled = this.disabled;
-
- if (!this.shouldUseGraphQL) {
- this.initialBoardLoad();
- }
- },
- methods: {
- ...mapActions(['setInitialBoardData', 'performSearch', 'setError']),
- initialBoardLoad() {
- boardsStore
- .all()
- .then((res) => res.data)
- .then((lists) => {
- lists.forEach((list) => boardsStore.addList(list));
- this.loading = false;
- })
- .catch((error) => {
- this.setError({
- error,
- message: __('An error occurred while fetching the board lists. Please try again.'),
- });
- });
- },
- updateTokens() {
- this.filterManager.updateTokens();
- },
- updateDetailIssue(newIssue, multiSelect = false) {
- const { sidebarInfoEndpoint } = newIssue;
- if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
- newIssue.setFetchingState('subscriptions', true);
- setWeightFetchingState(newIssue, true);
- setEpicFetchingState(newIssue, true);
- boardsStore
- .getIssueInfo(sidebarInfoEndpoint)
- .then((res) => res.data)
- .then((data) => {
- const {
- subscribed,
- totalTimeSpent,
- timeEstimate,
- humanTimeEstimate,
- humanTotalTimeSpent,
- weight,
- epic,
- assignees,
- } = convertObjectPropsToCamelCase(data);
-
- newIssue.setFetchingState('subscriptions', false);
- setWeightFetchingState(newIssue, false);
- setEpicFetchingState(newIssue, false);
- newIssue.updateData({
- humanTimeSpent: humanTotalTimeSpent,
- timeSpent: totalTimeSpent,
- humanTimeEstimate,
- timeEstimate,
- subscribed,
- weight,
- epic,
- assignees,
- });
- })
- .catch(() => {
- newIssue.setFetchingState('subscriptions', false);
- setWeightFetchingState(newIssue, false);
- this.setError({ message: __('An error occurred while fetching sidebar data') });
- });
- }
-
- if (multiSelect) {
- boardsStore.toggleMultiSelect(newIssue);
-
- if (boardsStore.detail.issue) {
- boardsStore.clearDetailIssue();
- return;
- }
-
- return;
- }
-
- boardsStore.setIssueDetail(newIssue);
- },
- clearDetailIssue(multiSelect = false) {
- if (multiSelect) {
- boardsStore.clearMultiSelect();
- }
- boardsStore.clearDetailIssue();
- },
- toggleSubscription(id) {
- const { issue } = boardsStore.detail;
- if (issue.id === id && issue.toggleSubscriptionEndpoint) {
- issue.setFetchingState('subscriptions', true);
- boardsStore
- .toggleIssueSubscription(issue.toggleSubscriptionEndpoint)
- .then(() => {
- issue.setFetchingState('subscriptions', false);
- issue.updateData({
- subscribed: !issue.subscribed,
- });
- })
- .catch(() => {
- issue.setFetchingState('subscriptions', false);
- this.setError({
- message: __('An error occurred when toggling the notification subscription'),
- });
- });
- }
- },
- getNodes(data) {
- return data[this.parent]?.board?.lists.nodes;
- },
- },
- });
-
- // eslint-disable-next-line no-new, @gitlab/no-runtime-template-compiler
- new Vue({
- el: document.getElementById('js-add-list'),
- data() {
- return {
- filters: boardsStore.state.filters,
- ...getMilestoneTitle($boardApp),
- };
- },
- mounted() {
- initNewListDropdown();
- },
- });
+ mountBoardApp($boardApp);
const createColumnTriggerEl = document.querySelector('.js-create-column-trigger');
if (createColumnTriggerEl) {
@@ -342,7 +140,7 @@ export default () => {
});
}
- boardConfigToggle(boardsStore);
+ boardConfigToggle();
toggleFocusMode();
toggleLabels();
diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js
deleted file mode 100644
index 1e822d06bfd..00000000000
--- a/app/assets/javascripts/boards/models/assignee.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export default class ListAssignee {
- constructor(obj) {
- this.id = obj.id;
- this.name = obj.name;
- this.username = obj.username;
- this.avatar = obj.avatarUrl || obj.avatar_url || obj.avatar || gon.default_avatar_url;
- this.path = obj.path;
- this.state = obj.state;
- this.webUrl = obj.web_url || obj.webUrl;
- }
-}
-
-window.ListAssignee = ListAssignee;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
deleted file mode 100644
index 46d1239457d..00000000000
--- a/app/assets/javascripts/boards/models/issue.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/* eslint-disable no-unused-vars */
-/* global ListLabel */
-/* global ListMilestone */
-/* global ListAssignee */
-
-import axios from '~/lib/utils/axios_utils';
-import './label';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import boardsStore from '../stores/boards_store';
-import IssueProject from './project';
-
-class ListIssue {
- constructor(obj) {
- this.subscribed = obj.subscribed;
- this.labels = [];
- this.assignees = [];
- this.selected = false;
- this.position = obj.position || obj.relative_position || obj.relativePosition || Infinity;
- this.isFetching = {
- subscriptions: true,
- };
- this.closed = obj.closed;
- this.isLoading = {};
-
- this.refreshData(obj);
- }
-
- refreshData(obj) {
- boardsStore.refreshIssueData(this, obj);
- }
-
- addLabel(label) {
- boardsStore.addIssueLabel(this, label);
- }
-
- findLabel(findLabel) {
- return boardsStore.findIssueLabel(this, findLabel);
- }
-
- removeLabel(removeLabel) {
- boardsStore.removeIssueLabel(this, removeLabel);
- }
-
- removeLabels(labels) {
- boardsStore.removeIssueLabels(this, labels);
- }
-
- addAssignee(assignee) {
- boardsStore.addIssueAssignee(this, assignee);
- }
-
- findAssignee(findAssignee) {
- return boardsStore.findIssueAssignee(this, findAssignee);
- }
-
- setAssignees(assignees) {
- boardsStore.setIssueAssignees(this, assignees);
- }
-
- removeAssignee(removeAssignee) {
- boardsStore.removeIssueAssignee(this, removeAssignee);
- }
-
- removeAllAssignees() {
- boardsStore.removeAllIssueAssignees(this);
- }
-
- addMilestone(milestone) {
- boardsStore.addIssueMilestone(this, milestone);
- }
-
- removeMilestone(removeMilestone) {
- boardsStore.removeIssueMilestone(this, removeMilestone);
- }
-
- getLists() {
- return boardsStore.state.lists.filter((list) => list.findIssue(this.id));
- }
-
- updateData(newData) {
- boardsStore.updateIssueData(this, newData);
- }
-
- setFetchingState(key, value) {
- boardsStore.setIssueFetchingState(this, key, value);
- }
-
- setLoadingState(key, value) {
- boardsStore.setIssueLoadingState(this, key, value);
- }
-
- update() {
- return boardsStore.updateIssue(this);
- }
-}
-
-window.ListIssue = ListIssue;
-
-export default ListIssue;
diff --git a/app/assets/javascripts/boards/models/iteration.js b/app/assets/javascripts/boards/models/iteration.js
deleted file mode 100644
index b7bdc204f7c..00000000000
--- a/app/assets/javascripts/boards/models/iteration.js
+++ /dev/null
@@ -1,9 +0,0 @@
-export default class ListIteration {
- constructor(obj) {
- this.id = obj.id;
- this.title = obj.title;
- this.state = obj.state;
- this.webUrl = obj.web_url || obj.webUrl;
- this.description = obj.description;
- }
-}
diff --git a/app/assets/javascripts/boards/models/label.js b/app/assets/javascripts/boards/models/label.js
deleted file mode 100644
index cd2a2c0137f..00000000000
--- a/app/assets/javascripts/boards/models/label.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-
-export default class ListLabel {
- constructor(obj) {
- Object.assign(this, convertObjectPropsToCamelCase(obj, { dropKeys: ['priority'] }), {
- priority: obj.priority !== null ? obj.priority : Infinity,
- });
- }
-}
-
-window.ListLabel = ListLabel;
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
deleted file mode 100644
index ab24532d87f..00000000000
--- a/app/assets/javascripts/boards/models/list.js
+++ /dev/null
@@ -1,182 +0,0 @@
-/* eslint-disable class-methods-use-this */
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-import boardsStore from '../stores/boards_store';
-import ListAssignee from './assignee';
-import ListIteration from './iteration';
-import ListLabel from './label';
-import ListMilestone from './milestone';
-import 'ee_else_ce/boards/models/issue';
-
-const TYPES = {
- backlog: {
- isPreset: true,
- isExpandable: true,
- isBlank: false,
- },
- closed: {
- isPreset: true,
- isExpandable: true,
- isBlank: false,
- },
- blank: {
- isPreset: true,
- isExpandable: false,
- isBlank: true,
- },
- default: {
- // includes label, assignee, and milestone lists
- isPreset: false,
- isExpandable: true,
- isBlank: false,
- },
-};
-
-class List {
- constructor(obj) {
- this.id = obj.id;
- this.position = obj.position;
- this.title = obj.title;
- this.type = obj.list_type || obj.listType;
-
- const typeInfo = this.getTypeInfo(this.type);
- this.preset = Boolean(typeInfo.isPreset);
- this.isExpandable = Boolean(typeInfo.isExpandable);
- this.isExpanded = !obj.collapsed;
- this.page = 1;
- this.highlighted = obj.highlighted;
- this.loading = true;
- this.loadingMore = false;
- this.issues = obj.issues || [];
- this.issuesSize = obj.issuesSize || obj.issuesCount || 0;
- this.maxIssueCount = obj.maxIssueCount || obj.max_issue_count || 0;
-
- if (obj.label) {
- this.label = new ListLabel(obj.label);
- } else if (obj.user || obj.assignee) {
- this.assignee = new ListAssignee(obj.user || obj.assignee);
- this.title = this.assignee.name;
- } else if (IS_EE && obj.milestone) {
- this.milestone = new ListMilestone(obj.milestone);
- this.title = this.milestone.title;
- } else if (IS_EE && obj.iteration) {
- this.iteration = new ListIteration(obj.iteration);
- this.title = this.iteration.title;
- }
-
- // doNotFetchIssues is a temporary workaround until issues are fetched using GraphQL on issue boards
- // Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/229416
- if (!typeInfo.isBlank && this.id && !obj.doNotFetchIssues) {
- this.getIssues().catch(() => {
- // TODO: handle request error
- });
- }
- }
-
- guid() {
- const s4 = () =>
- Math.floor((1 + Math.random()) * 0x10000)
- .toString(16)
- .substring(1);
- return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`;
- }
-
- save() {
- return boardsStore.saveList(this);
- }
-
- destroy() {
- boardsStore.destroy(this);
- }
-
- update() {
- return boardsStore.updateListFunc(this);
- }
-
- nextPage() {
- return boardsStore.goToNextPage(this);
- }
-
- getIssues(emptyIssues = true) {
- return boardsStore.getListIssues(this, emptyIssues);
- }
-
- newIssue(issue) {
- return boardsStore.newListIssue(this, issue);
- }
-
- addMultipleIssues(issues, listFrom, newIndex) {
- boardsStore.addMultipleListIssues(this, issues, listFrom, newIndex);
- }
-
- addIssue(issue, listFrom, newIndex) {
- boardsStore.addListIssue(this, issue, listFrom, newIndex);
- }
-
- moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
- boardsStore.moveListIssues(this, issue, oldIndex, newIndex, moveBeforeId, moveAfterId);
- }
-
- moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
- boardsStore
- .moveListMultipleIssues({
- list: this,
- issues,
- oldIndicies,
- newIndex,
- moveBeforeId,
- moveAfterId,
- })
- .catch(() =>
- createFlash({
- message: __('Something went wrong while moving issues.'),
- }),
- );
- }
-
- updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId) {
- boardsStore.moveIssue(issue.id, listFrom.id, this.id, moveBeforeId, moveAfterId).catch(() => {
- // TODO: handle request error
- });
- }
-
- updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId) {
- boardsStore
- .moveMultipleIssues({
- ids: issues.map((issue) => issue.id),
- fromListId: listFrom.id,
- toListId: this.id,
- moveBeforeId,
- moveAfterId,
- })
- .catch(() =>
- createFlash({
- message: __('Something went wrong while moving issues.'),
- }),
- );
- }
-
- findIssue(id) {
- return boardsStore.findListIssue(this, id);
- }
-
- removeMultipleIssues(removeIssues) {
- return boardsStore.removeListMultipleIssues(this, removeIssues);
- }
-
- removeIssue(removeIssue) {
- return boardsStore.removeListIssues(this, removeIssue);
- }
-
- getTypeInfo(type) {
- return TYPES[type] || TYPES.default;
- }
-
- onNewIssueResponse(issue, data) {
- boardsStore.onNewListIssueResponse(this, issue, data);
- }
-}
-
-window.List = List;
-
-export default List;
diff --git a/app/assets/javascripts/boards/models/milestone.js b/app/assets/javascripts/boards/models/milestone.js
deleted file mode 100644
index 7201b6e91f5..00000000000
--- a/app/assets/javascripts/boards/models/milestone.js
+++ /dev/null
@@ -1,15 +0,0 @@
-export default class ListMilestone {
- constructor(obj) {
- this.id = obj.id;
- this.title = obj.title;
-
- if (IS_EE) {
- this.path = obj.path;
- this.state = obj.state;
- this.webUrl = obj.web_url || obj.webUrl;
- this.description = obj.description;
- }
- }
-}
-
-window.ListMilestone = ListMilestone;
diff --git a/app/assets/javascripts/boards/models/project.js b/app/assets/javascripts/boards/models/project.js
deleted file mode 100644
index 9468a02856e..00000000000
--- a/app/assets/javascripts/boards/models/project.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export default class IssueProject {
- constructor(obj) {
- this.id = obj.id;
- this.path = obj.path;
- this.fullPath = obj.path_with_namespace;
- }
-}
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
index 7d6179a8547..a3a8ad06c43 100644
--- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -1,12 +1,9 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { mapGetters } from 'vuex';
import BoardsSelector from 'ee_else_ce/boards/components/boards_selector.vue';
-import BoardsSelectorDeprecated from '~/boards/components/boards_selector_deprecated.vue';
import store from '~/boards/stores';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
Vue.use(VueApollo);
@@ -25,9 +22,7 @@ export default (params = {}) => {
el: boardsSwitcherElement,
components: {
BoardsSelector,
- BoardsSelectorDeprecated,
},
- mixins: [glFeatureFlagMixin()],
apolloProvider,
store,
provide: {
@@ -52,16 +47,8 @@ export default (params = {}) => {
return { boardsSelectorProps };
},
- computed: {
- ...mapGetters(['shouldUseGraphQL', 'isEpicBoard']),
- },
render(createElement) {
- if (this.shouldUseGraphQL || this.isEpicBoard) {
- return createElement(BoardsSelector, {
- props: this.boardsSelectorProps,
- });
- }
- return createElement(BoardsSelectorDeprecated, {
+ 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 970d00841bd..dc06b62cebb 100644
--- a/app/assets/javascripts/boards/stores/actions.js
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -36,11 +36,13 @@ import {
filterVariables,
} from '../boards_util';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
+import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
import issueCreateMutation from '../graphql/issue_create.mutation.graphql';
import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql';
import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
+import projectBoardIterationsQuery from '../graphql/project_board_iterations.query.graphql';
import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
import * as types from './mutation_types';
@@ -82,11 +84,8 @@ export default {
'setFilters',
convertObjectPropsToCamelCase(queryToObject(window.location.search, { gatherArrays: true })),
);
-
- if (gon.features.graphqlBoardLists) {
- dispatch('fetchLists');
- dispatch('resetIssues');
- }
+ dispatch('fetchLists');
+ dispatch('resetIssues');
},
fetchLists: ({ commit, state, dispatch }) => {
@@ -182,7 +181,7 @@ export default {
});
},
- fetchLabels: ({ state, commit, getters }, searchTerm) => {
+ fetchLabels: ({ state, commit }, searchTerm) => {
const { fullPath, boardType } = state;
const variables = {
@@ -200,14 +199,7 @@ export default {
variables,
})
.then(({ data }) => {
- let labels = data[boardType]?.labels.nodes;
-
- if (!getters.shouldUseGraphQL && !getters.isEpicBoard) {
- labels = labels.map((label) => ({
- ...label,
- id: getIdFromGraphQLId(label.id),
- }));
- }
+ const labels = data[boardType]?.labels.nodes;
commit(types.RECEIVE_LABELS_SUCCESS, labels);
return labels;
@@ -218,6 +210,52 @@ export default {
});
},
+ fetchIterations({ state, commit }, title) {
+ commit(types.RECEIVE_ITERATIONS_REQUEST);
+
+ const { fullPath, boardType } = state;
+
+ const variables = {
+ fullPath,
+ title,
+ };
+
+ let query;
+ if (boardType === BoardType.project) {
+ query = projectBoardIterationsQuery;
+ }
+ if (boardType === BoardType.group) {
+ query = groupBoardIterationsQuery;
+ }
+
+ if (!query) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Unknown board type');
+ }
+
+ return gqlClient
+ .query({
+ query,
+ variables,
+ })
+ .then(({ data }) => {
+ const errors = data[boardType]?.errors;
+ const iterations = data[boardType]?.iterations.nodes;
+
+ if (errors?.[0]) {
+ throw new Error(errors[0]);
+ }
+
+ commit(types.RECEIVE_ITERATIONS_SUCCESS, iterations);
+
+ return iterations;
+ })
+ .catch((e) => {
+ commit(types.RECEIVE_ITERATIONS_FAILURE);
+ throw e;
+ });
+ },
+
fetchMilestones({ state, commit }, searchTerm) {
commit(types.RECEIVE_MILESTONES_REQUEST);
@@ -536,8 +574,8 @@ export default {
boardId: fullBoardId,
fromListId: getIdFromGraphQLId(fromListId),
toListId: getIdFromGraphQLId(toListId),
- moveBeforeId,
- moveAfterId,
+ moveBeforeId: moveBeforeId ? getIdFromGraphQLId(moveBeforeId) : undefined,
+ moveAfterId: moveAfterId ? getIdFromGraphQLId(moveAfterId) : undefined,
// 'mutationVariables' allows EE code to pass in extra parameters.
...mutationVariables,
},
@@ -604,7 +642,7 @@ export default {
}
const rawIssue = data.createIssue?.issue;
- const formattedIssue = formatIssue({ ...rawIssue, id: getIdFromGraphQLId(rawIssue.id) });
+ const formattedIssue = formatIssue(rawIssue);
dispatch('removeListItem', { listId: list.id, itemId: placeholderId });
dispatch('addListItem', { list, item: formattedIssue, position: 0 });
})
@@ -640,7 +678,7 @@ export default {
}
commit(types.UPDATE_BOARD_ITEM_BY_ID, {
- itemId: getIdFromGraphQLId(data.updateIssue?.issue?.id) || activeBoardItem.id,
+ itemId: data.updateIssue?.issue?.id || activeBoardItem.id,
prop: 'labels',
value: data.updateIssue.issue.labels.nodes,
});
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
deleted file mode 100644
index 857b0912c57..00000000000
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ /dev/null
@@ -1,883 +0,0 @@
-/* eslint-disable no-shadow, no-param-reassign,consistent-return */
-/* global List */
-/* global ListIssue */
-import { sortBy } from 'lodash';
-import Vue from 'vue';
-import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import createDefaultClient from '~/lib/graphql';
-import axios from '~/lib/utils/axios_utils';
-import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { mergeUrlParams, queryToObject, getUrlParamsArray } from '~/lib/utils/url_utility';
-import { ListType, flashAnimationDuration } from '../constants';
-import eventHub from '../eventhub';
-import ListAssignee from '../models/assignee';
-import ListLabel from '../models/label';
-import ListMilestone from '../models/milestone';
-import IssueProject from '../models/project';
-
-const PER_PAGE = 20;
-export const gqlClient = createDefaultClient();
-
-const boardsStore = {
- disabled: false,
- timeTracking: {
- limitToHours: false,
- },
- scopedLabels: {
- enabled: false,
- },
- filter: {
- path: '',
- },
- state: {
- currentBoard: {
- labels: [],
- },
- currentPage: '',
- endpoints: {},
- },
- detail: {
- issue: {},
- list: {},
- },
- moving: {
- issue: {},
- list: {},
- },
- multiSelect: { list: [] },
-
- setEndpoints({
- boardsEndpoint,
- listsEndpoint,
- bulkUpdatePath,
- boardId,
- recentBoardsEndpoint,
- fullPath,
- }) {
- const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
- this.state.endpoints = {
- boardsEndpoint,
- boardId,
- listsEndpoint,
- listsEndpointGenerate,
- bulkUpdatePath,
- fullPath,
- recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
- };
- },
- create() {
- this.state.lists = [];
- this.filter.path = getUrlParamsArray().join('&');
- this.detail = {
- issue: {},
- list: {},
- };
- },
- showPage(page) {
- this.state.currentPage = page;
- },
- updateListPosition(listObj) {
- const listType = listObj.listType || listObj.list_type;
- let { position } = listObj;
- if (listType === ListType.closed) {
- position = Infinity;
- } else if (listType === ListType.backlog) {
- position = -1;
- }
-
- const list = new List({ ...listObj, position });
- return list;
- },
- addList(listObj) {
- const list = this.updateListPosition(listObj);
- this.state.lists = sortBy([...this.state.lists, list], 'position');
- return list;
- },
- new(listObj) {
- const list = this.addList(listObj);
- const backlogList = this.findList('type', 'backlog');
-
- list
- .save()
- .then(() => {
- list.highlighted = true;
- setTimeout(() => {
- list.highlighted = false;
- }, flashAnimationDuration);
-
- // Remove any new issues from the backlog
- // as they will be visible in the new list
- list.issues.forEach(backlogList.removeIssue.bind(backlogList));
- this.state.lists = sortBy(this.state.lists, 'position');
- })
- .catch(() => {
- // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821
- });
- },
-
- updateNewListDropdown(listId) {
- document
- .querySelector(`.js-board-list-${getIdFromGraphQLId(listId)}`)
- ?.classList.remove('is-active');
- },
-
- findIssueLabel(issue, findLabel) {
- return issue.labels.find((label) => label.id === findLabel.id);
- },
-
- goToNextPage(list) {
- if (list.issuesSize > list.issues.length) {
- if (list.issues.length / PER_PAGE >= 1) {
- list.page += 1;
- }
-
- return list.getIssues(false);
- }
- },
-
- addListIssue(list, issue, listFrom, newIndex) {
- let moveBeforeId = null;
- let moveAfterId = null;
-
- if (!list.findIssue(issue.id)) {
- if (newIndex !== undefined) {
- list.issues.splice(newIndex, 0, issue);
-
- if (list.issues[newIndex - 1]) {
- moveBeforeId = list.issues[newIndex - 1].id;
- }
-
- if (list.issues[newIndex + 1]) {
- moveAfterId = list.issues[newIndex + 1].id;
- }
- } else {
- list.issues.push(issue);
- }
-
- if (list.label) {
- issue.addLabel(list.label);
- }
-
- if (list.assignee) {
- if (listFrom && listFrom.type === 'assignee') {
- issue.removeAssignee(listFrom.assignee);
- }
- issue.addAssignee(list.assignee);
- }
-
- if (IS_EE && list.milestone) {
- if (listFrom && listFrom.type === 'milestone') {
- issue.removeMilestone(listFrom.milestone);
- }
- issue.addMilestone(list.milestone);
- }
-
- if (listFrom) {
- list.issuesSize += 1;
-
- list.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
- }
- }
- },
- findListIssue(list, id) {
- return list.issues.find((issue) => issue.id === id);
- },
-
- removeList(id) {
- const list = this.findList('id', id);
-
- if (!list) return;
-
- this.state.lists = this.state.lists.filter((list) => list.id !== id);
- },
- moveList(listFrom, orderLists) {
- orderLists.forEach((id, i) => {
- const list = this.findList('id', parseInt(id, 10));
-
- list.position = i;
- });
- listFrom.update();
- },
-
- addMultipleListIssues(list, issues, listFrom, newIndex) {
- let moveBeforeId = null;
- let moveAfterId = null;
-
- const listHasIssues = issues.every((issue) => list.findIssue(issue.id));
-
- if (!listHasIssues) {
- if (newIndex !== undefined) {
- if (list.issues[newIndex - 1]) {
- moveBeforeId = list.issues[newIndex - 1].id;
- }
-
- if (list.issues[newIndex]) {
- moveAfterId = list.issues[newIndex].id;
- }
-
- list.issues.splice(newIndex, 0, ...issues);
- } else {
- list.issues.push(...issues);
- }
-
- if (list.label) {
- issues.forEach((issue) => issue.addLabel(list.label));
- }
-
- if (list.assignee) {
- if (listFrom && listFrom.type === 'assignee') {
- issues.forEach((issue) => issue.removeAssignee(listFrom.assignee));
- }
- issues.forEach((issue) => issue.addAssignee(list.assignee));
- }
-
- if (IS_EE && list.milestone) {
- if (listFrom && listFrom.type === 'milestone') {
- issues.forEach((issue) => issue.removeMilestone(listFrom.milestone));
- }
- issues.forEach((issue) => issue.addMilestone(list.milestone));
- }
-
- if (listFrom) {
- list.issuesSize += issues.length;
-
- list.updateMultipleIssues(issues, listFrom, moveBeforeId, moveAfterId);
- }
- }
- },
-
- removeListIssues(list, removeIssue) {
- list.issues = list.issues.filter((issue) => {
- const matchesRemove = removeIssue.id === issue.id;
-
- if (matchesRemove) {
- list.issuesSize -= 1;
- issue.removeLabel(list.label);
- }
-
- return !matchesRemove;
- });
- },
- removeListMultipleIssues(list, removeIssues) {
- const ids = removeIssues.map((issue) => issue.id);
-
- list.issues = list.issues.filter((issue) => {
- const matchesRemove = ids.includes(issue.id);
-
- if (matchesRemove) {
- list.issuesSize -= 1;
- issue.removeLabel(list.label);
- }
-
- return !matchesRemove;
- });
- },
-
- startMoving(list, issue) {
- Object.assign(this.moving, { list, issue });
- },
-
- onNewListIssueResponse(list, issue, data) {
- issue.refreshData(data);
-
- if (list.issues.length > 1) {
- const moveBeforeId = list.issues[1].id;
- this.moveIssue(issue.id, null, null, null, moveBeforeId);
- }
- },
-
- moveMultipleIssuesToList({ listFrom, listTo, issues, newIndex }) {
- const issueTo = issues.map((issue) => listTo.findIssue(issue.id));
- const issueLists = issues.map((issue) => issue.getLists()).flat();
- const listLabels = issueLists.map((list) => list.label);
- const hasMoveableIssues = issueTo.filter(Boolean).length > 0;
-
- if (!hasMoveableIssues) {
- // Check if target list assignee is already present in this issue
- if (
- listTo.type === ListType.assignee &&
- listFrom.type === ListType.assignee &&
- issues.some((issue) => issue.findAssignee(listTo.assignee))
- ) {
- const targetIssues = issues.map((issue) => listTo.findIssue(issue.id));
- targetIssues.forEach((targetIssue) => targetIssue.removeAssignee(listFrom.assignee));
- } else if (listTo.type === 'milestone') {
- const currentMilestones = issues.map((issue) => issue.milestone);
- const currentLists = this.state.lists
- .filter((list) => list.type === 'milestone' && list.id !== listTo.id)
- .filter((list) =>
- list.issues.some((listIssue) => issues.some((issue) => listIssue.id === issue.id)),
- );
-
- issues.forEach((issue) => {
- currentMilestones.forEach((milestone) => {
- issue.removeMilestone(milestone);
- });
- });
-
- issues.forEach((issue) => {
- issue.addMilestone(listTo.milestone);
- });
-
- currentLists.forEach((currentList) => {
- issues.forEach((issue) => {
- currentList.removeIssue(issue);
- });
- });
-
- listTo.addMultipleIssues(issues, listFrom, newIndex);
- } else {
- // Add to new lists issues if it doesn't already exist
- listTo.addMultipleIssues(issues, listFrom, newIndex);
- }
- } else {
- listTo.updateMultipleIssues(issues, listFrom);
- issues.forEach((issue) => {
- issue.removeLabel(listFrom.label);
- });
- }
-
- if (listTo.type === ListType.closed && listFrom.type !== ListType.backlog) {
- issueLists.forEach((list) => {
- issues.forEach((issue) => {
- list.removeIssue(issue);
- });
- });
-
- issues.forEach((issue) => {
- issue.removeLabels(listLabels);
- });
- } else if (listTo.type === ListType.backlog && listFrom.type === ListType.assignee) {
- issues.forEach((issue) => {
- issue.removeAssignee(listFrom.assignee);
- });
- issueLists.forEach((list) => {
- issues.forEach((issue) => {
- list.removeIssue(issue);
- });
- });
- } else if (listTo.type === ListType.backlog && listFrom.type === ListType.milestone) {
- issues.forEach((issue) => {
- issue.removeMilestone(listFrom.milestone);
- });
- issueLists.forEach((list) => {
- issues.forEach((issue) => {
- list.removeIssue(issue);
- });
- });
- } else if (
- this.shouldRemoveIssue(listFrom, listTo) &&
- this.issuesAreContiguous(listFrom, issues)
- ) {
- listFrom.removeMultipleIssues(issues);
- }
- },
-
- issuesAreContiguous(list, issues) {
- // When there's only 1 issue selected, we can return early.
- if (issues.length === 1) return true;
-
- // Create list of ids for issues involved.
- const listIssueIds = list.issues.map((issue) => issue.id);
- const movedIssueIds = issues.map((issue) => issue.id);
-
- // Check if moved issue IDs is sub-array
- // of source list issue IDs (i.e. contiguous selection).
- return listIssueIds.join('|').includes(movedIssueIds.join('|'));
- },
-
- moveIssueToList(listFrom, listTo, issue, newIndex) {
- const issueTo = listTo.findIssue(issue.id);
- const issueLists = issue.getLists();
- const listLabels = issueLists.map((listIssue) => listIssue.label);
-
- if (!issueTo) {
- // Check if target list assignee is already present in this issue
- if (
- listTo.type === 'assignee' &&
- listFrom.type === 'assignee' &&
- issue.findAssignee(listTo.assignee)
- ) {
- const targetIssue = listTo.findIssue(issue.id);
- targetIssue.removeAssignee(listFrom.assignee);
- } else if (listTo.type === 'milestone') {
- const currentMilestone = issue.milestone;
- const currentLists = this.state.lists
- .filter((list) => list.type === 'milestone' && list.id !== listTo.id)
- .filter((list) => list.issues.some((listIssue) => issue.id === listIssue.id));
-
- issue.removeMilestone(currentMilestone);
- issue.addMilestone(listTo.milestone);
- currentLists.forEach((currentList) => currentList.removeIssue(issue));
- listTo.addIssue(issue, listFrom, newIndex);
- } else {
- // Add to new lists issues if it doesn't already exist
- listTo.addIssue(issue, listFrom, newIndex);
- }
- } else {
- listTo.updateIssueLabel(issue, listFrom);
- issueTo.removeLabel(listFrom.label);
- }
-
- if (listTo.type === 'closed' && listFrom.type !== 'backlog') {
- issueLists.forEach((list) => {
- list.removeIssue(issue);
- });
- issue.removeLabels(listLabels);
- } else if (listTo.type === 'backlog' && listFrom.type === 'assignee') {
- issue.removeAssignee(listFrom.assignee);
- listFrom.removeIssue(issue);
- } else if (listTo.type === 'backlog' && listFrom.type === 'milestone') {
- issue.removeMilestone(listFrom.milestone);
- listFrom.removeIssue(issue);
- } else if (this.shouldRemoveIssue(listFrom, listTo)) {
- listFrom.removeIssue(issue);
- }
- },
- shouldRemoveIssue(listFrom, listTo) {
- return (
- (listTo.type !== 'label' && listFrom.type === 'assignee') ||
- (listTo.type !== 'assignee' && listFrom.type === 'label') ||
- listFrom.type === 'backlog' ||
- listFrom.type === 'closed'
- );
- },
- moveIssueInList(list, issue, oldIndex, newIndex, idArray) {
- const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
- const afterId = parseInt(idArray[newIndex + 1], 10) || null;
-
- list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
- },
- moveMultipleIssuesInList({ list, issues, oldIndicies, newIndex, idArray }) {
- const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
- const afterId = parseInt(idArray[newIndex + issues.length], 10) || null;
- list.moveMultipleIssues({
- issues,
- oldIndicies,
- newIndex,
- moveBeforeId: beforeId,
- moveAfterId: afterId,
- });
- },
- findList(key, val) {
- return this.state.lists.find((list) => list[key] === val);
- },
- findListByLabelId(id) {
- return this.state.lists.find((list) => list.type === 'label' && list.label.id === id);
- },
-
- toggleFilter(filter) {
- const filterPath = this.filter.path.split('&');
- const filterIndex = filterPath.indexOf(filter);
-
- if (filterIndex === -1) {
- filterPath.push(filter);
- } else {
- filterPath.splice(filterIndex, 1);
- }
-
- this.filter.path = filterPath.join('&');
-
- this.updateFiltersUrl();
-
- eventHub.$emit('updateTokens');
- },
-
- setListDetail(newList) {
- this.detail.list = newList;
- },
-
- updateFiltersUrl() {
- window.history.pushState(null, null, `?${this.filter.path}`);
- },
-
- clearDetailIssue() {
- this.setIssueDetail({});
- },
-
- setIssueDetail(issueDetail) {
- this.detail.issue = issueDetail;
- },
-
- setTimeTrackingLimitToHours(limitToHours) {
- this.timeTracking.limitToHours = parseBoolean(limitToHours);
- },
-
- generateBoardGid(boardId) {
- return `gid://gitlab/Board/${boardId}`;
- },
-
- generateBoardsPath(id) {
- return `${this.state.endpoints.boardsEndpoint}${id ? `/${id}` : ''}.json`;
- },
-
- generateIssuesPath(id) {
- return `${this.state.endpoints.listsEndpoint}${id ? `/${id}` : ''}/issues`;
- },
-
- generateIssuePath(boardId, id) {
- return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${
- id ? `/${id}` : ''
- }`;
- },
-
- generateMultiDragPath(boardId) {
- return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues/bulk_move`;
- },
-
- all() {
- return axios.get(this.state.endpoints.listsEndpoint);
- },
-
- createList(entityId, entityType) {
- const list = {
- [entityType]: entityId,
- };
-
- return axios.post(this.state.endpoints.listsEndpoint, {
- list,
- });
- },
-
- updateList(id, position, collapsed) {
- return axios.put(`${this.state.endpoints.listsEndpoint}/${id}`, {
- list: {
- position,
- collapsed,
- },
- });
- },
-
- updateListFunc(list) {
- const collapsed = !list.isExpanded;
- return this.updateList(list.id, list.position, collapsed).catch(() => {
- // TODO: handle request error
- });
- },
-
- destroyList(id) {
- return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`);
- },
- destroy(list) {
- const index = this.state.lists.indexOf(list);
- this.state.lists.splice(index, 1);
- this.updateNewListDropdown(list.id);
-
- this.destroyList(list.id).catch(() => {
- // TODO: handle request error
- });
- },
-
- saveList(list) {
- const entity = list.label || list.assignee || list.milestone || list.iteration;
- let entityType = '';
- if (list.label) {
- entityType = 'label_id';
- } else if (list.assignee) {
- entityType = 'assignee_id';
- } else if (IS_EE && list.milestone) {
- entityType = 'milestone_id';
- } else if (IS_EE && list.iteration) {
- entityType = 'iteration_id';
- }
-
- return this.createList(entity.id, entityType)
- .then((res) => res.data)
- .then((data) => {
- list.id = data.id;
- list.type = data.list_type;
- list.position = data.position;
- list.label = data.label;
-
- return list.getIssues();
- });
- },
-
- getListIssues(list, emptyIssues = true) {
- const data = {
- ...queryToObject(this.filter.path, { gatherArrays: true }),
- page: list.page,
- };
-
- if (list.label && data.label_name) {
- data.label_name = data.label_name.filter((label) => label !== list.label.title);
- }
-
- if (emptyIssues) {
- list.loading = true;
- }
-
- return this.getIssuesForList(list.id, data)
- .then((res) => res.data)
- .then((data) => {
- list.loading = false;
- list.issuesSize = data.size;
-
- if (emptyIssues) {
- list.issues = [];
- }
-
- data.issues.forEach((issueObj) => {
- list.addIssue(new ListIssue(issueObj));
- });
-
- return data;
- });
- },
-
- getIssuesForList(id, filter = {}) {
- const data = { id };
- Object.keys(filter).forEach((key) => {
- data[key] = filter[key];
- });
-
- return axios.get(mergeUrlParams(data, this.generateIssuesPath(id)));
- },
-
- moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) {
- return axios.put(this.generateIssuePath(this.state.endpoints.boardId, id), {
- from_list_id: fromListId,
- to_list_id: toListId,
- move_before_id: moveBeforeId,
- move_after_id: moveAfterId,
- });
- },
-
- moveListIssues(list, issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
- list.issues.splice(oldIndex, 1);
- list.issues.splice(newIndex, 0, issue);
-
- this.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
- // TODO: handle request error
- });
- },
-
- moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) {
- return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), {
- from_list_id: fromListId,
- to_list_id: toListId,
- move_before_id: moveBeforeId,
- move_after_id: moveAfterId,
- ids,
- });
- },
-
- moveListMultipleIssues({ list, issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
- oldIndicies.reverse().forEach((index) => {
- list.issues.splice(index, 1);
- });
- list.issues.splice(newIndex, 0, ...issues);
-
- return this.moveMultipleIssues({
- ids: issues.map((issue) => issue.id),
- fromListId: null,
- toListId: null,
- moveBeforeId,
- moveAfterId,
- });
- },
-
- newIssue(id, issue) {
- if (typeof id === 'string') {
- id = getIdFromGraphQLId(id);
- }
-
- return axios.post(this.generateIssuesPath(id), {
- issue,
- });
- },
-
- newListIssue(list, issue) {
- list.addIssue(issue, null, 0);
- list.issuesSize += 1;
- let listId = list.id;
- if (typeof listId === 'string') {
- listId = getIdFromGraphQLId(listId);
- }
-
- return this.newIssue(list.id, issue)
- .then((res) => res.data)
- .then((data) => list.onNewIssueResponse(issue, data));
- },
-
- getBacklog(data) {
- return axios.get(
- mergeUrlParams(
- data,
- `${gon.relative_url_root}/-/boards/${this.state.endpoints.boardId}/issues.json`,
- ),
- );
- },
- removeIssueLabel(issue, removeLabel) {
- if (removeLabel) {
- issue.labels = issue.labels.filter((label) => removeLabel.id !== label.id);
- }
- },
-
- addIssueAssignee(issue, assignee) {
- if (!issue.findAssignee(assignee)) {
- issue.assignees.push(new ListAssignee(assignee));
- }
- },
-
- setIssueAssignees(issue, assignees) {
- issue.assignees = [...assignees];
- },
-
- removeIssueLabels(issue, labels) {
- labels.forEach(issue.removeLabel.bind(issue));
- },
-
- bulkUpdate(issueIds, extraData = {}) {
- const data = {
- update: Object.assign(extraData, {
- issuable_ids: issueIds.join(','),
- }),
- };
-
- return axios.post(this.state.endpoints.bulkUpdatePath, data);
- },
-
- getIssueInfo(endpoint) {
- return axios.get(endpoint);
- },
-
- toggleIssueSubscription(endpoint) {
- return axios.post(endpoint);
- },
-
- recentBoards() {
- return axios.get(this.state.endpoints.recentBoardsEndpoint);
- },
-
- setCurrentBoard(board) {
- this.state.currentBoard = board;
- },
-
- toggleMultiSelect(issue) {
- const selectedIssueIds = this.multiSelect.list.map((issue) => issue.id);
- const index = selectedIssueIds.indexOf(issue.id);
-
- if (index === -1) {
- this.multiSelect.list.push(issue);
- return;
- }
-
- this.multiSelect.list = [
- ...this.multiSelect.list.slice(0, index),
- ...this.multiSelect.list.slice(index + 1),
- ];
- },
- removeIssueAssignee(issue, removeAssignee) {
- if (removeAssignee) {
- issue.assignees = issue.assignees.filter((assignee) => assignee.id !== removeAssignee.id);
- }
- },
-
- findIssueAssignee(issue, findAssignee) {
- return issue.assignees.find((assignee) => assignee.id === findAssignee.id);
- },
-
- clearMultiSelect() {
- this.multiSelect.list = [];
- },
-
- removeAllIssueAssignees(issue) {
- issue.assignees = [];
- },
-
- addIssueMilestone(issue, milestone) {
- const miletoneId = issue.milestone ? issue.milestone.id : null;
- if (IS_EE && milestone.id !== miletoneId) {
- issue.milestone = new ListMilestone(milestone);
- }
- },
-
- setIssueLoadingState(issue, key, value) {
- issue.isLoading[key] = value;
- },
-
- updateIssueData(issue, newData) {
- Object.assign(issue, newData);
- },
-
- setIssueFetchingState(issue, key, value) {
- issue.isFetching[key] = value;
- },
-
- removeIssueMilestone(issue, removeMilestone) {
- if (IS_EE && removeMilestone && removeMilestone.id === issue.milestone.id) {
- issue.milestone = {};
- }
- },
-
- refreshIssueData(issue, obj) {
- const convertedObj = convertObjectPropsToCamelCase(obj, {
- dropKeys: ['issue_sidebar_endpoint', 'real_path', 'webUrl'],
- });
- convertedObj.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
- issue.path = obj.real_path || obj.webUrl;
- issue.project_id = obj.project_id;
- Object.assign(issue, convertedObj);
-
- if (obj.project) {
- issue.project = new IssueProject(obj.project);
- }
-
- if (obj.milestone) {
- issue.milestone = new ListMilestone(obj.milestone);
- issue.milestone_id = obj.milestone.id;
- }
-
- if (obj.labels) {
- issue.labels = obj.labels.map((label) => new ListLabel(label));
- }
-
- if (obj.assignees) {
- issue.assignees = obj.assignees.map((a) => new ListAssignee(a));
- }
- },
- addIssueLabel(issue, label) {
- if (!issue.findLabel(label)) {
- issue.labels.push(new ListLabel(label));
- }
- },
- updateIssue(issue) {
- const data = {
- issue: {
- milestone_id: issue.milestone ? issue.milestone.id : null,
- due_date: issue.dueDate,
- assignee_ids: issue.assignees.length > 0 ? issue.assignees.map(({ id }) => id) : [0],
- label_ids: issue.labels.length > 0 ? issue.labels.map(({ id }) => id) : [''],
- },
- };
-
- return axios.patch(`${issue.path}.json`, data).then(({ data: body = {} } = {}) => {
- /**
- * Since post implementation of Scoped labels, server can reject
- * same key-ed labels. To keep the UI and server Model consistent,
- * we're just assigning labels that server echo's back to us when we
- * PATCH the said object.
- */
- if (body) {
- issue.labels = convertObjectPropsToCamelCase(body.labels, { deep: true });
- }
- });
- },
-};
-
-BoardsStoreEE.initEESpecific(boardsStore);
-
-// hacks added in order to allow milestone_select to function properly
-// TODO: remove these
-
-export function boardStoreIssueSet(...args) {
- Vue.set(boardsStore.detail.issue, ...args);
-}
-
-export function boardStoreIssueDelete(...args) {
- Vue.delete(boardsStore.detail.issue, ...args);
-}
-
-export default boardsStore;
diff --git a/app/assets/javascripts/boards/stores/boards_store_ee.js b/app/assets/javascripts/boards/stores/boards_store_ee.js
deleted file mode 100644
index 2a289ce5d0a..00000000000
--- a/app/assets/javascripts/boards/stores/boards_store_ee.js
+++ /dev/null
@@ -1,5 +0,0 @@
-// this is just to make ee_else_ce happy and will be cleaned up in https://gitlab.com/gitlab-org/gitlab-foss/issues/59807
-
-export default {
- initEESpecific() {},
-};
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
index 140c9ef7ac4..cb31eb4b008 100644
--- a/app/assets/javascripts/boards/stores/getters.js
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -16,7 +16,7 @@ export default {
},
activeBoardItem: (state) => {
- return state.boardItems[state.activeId] || { iid: '', id: '', fullId: '' };
+ return state.boardItems[state.activeId] || { iid: '', id: '' };
},
groupPathForActiveIssue: (_, getters) => {
@@ -51,8 +51,4 @@ export default {
isEpicBoard: () => {
return false;
},
-
- shouldUseGraphQL: () => {
- return gon?.features?.graphqlBoardLists;
- },
};
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
index 31b78014525..928cece19f7 100644
--- a/app/assets/javascripts/boards/stores/mutation_types.js
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -41,3 +41,7 @@ export const ADD_LIST_TO_HIGHLIGHTED_LISTS = 'ADD_LIST_TO_HIGHLIGHTED_LISTS';
export const REMOVE_LIST_FROM_HIGHLIGHTED_LISTS = 'REMOVE_LIST_FROM_HIGHLIGHTED_LISTS';
export const RESET_BOARD_ITEM_SELECTION = 'RESET_BOARD_ITEM_SELECTION';
export const SET_ERROR = 'SET_ERROR';
+
+export const RECEIVE_ITERATIONS_REQUEST = 'RECEIVE_ITERATIONS_REQUEST';
+export const RECEIVE_ITERATIONS_SUCCESS = 'RECEIVE_ITERATIONS_SUCCESS';
+export const RECEIVE_ITERATIONS_FAILURE = 'RECEIVE_ITERATIONS_FAILURE';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
index 668a3dbaa7e..ef5b84b4575 100644
--- a/app/assets/javascripts/boards/stores/mutations.js
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -1,6 +1,5 @@
import { cloneDeep, pull, union } from 'lodash';
import Vue from 'vue';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__, __ } from '~/locale';
import { formatIssue } from '../boards_util';
import { issuableTypes } from '../constants';
@@ -65,6 +64,20 @@ export default {
);
},
+ [mutationTypes.RECEIVE_ITERATIONS_REQUEST](state) {
+ state.iterationsLoading = true;
+ },
+
+ [mutationTypes.RECEIVE_ITERATIONS_SUCCESS](state, iterations) {
+ state.iterations = iterations;
+ state.iterationsLoading = false;
+ },
+
+ [mutationTypes.RECEIVE_ITERATIONS_FAILURE](state) {
+ state.iterationsLoading = false;
+ state.error = __('Failed to load iterations.');
+ },
+
[mutationTypes.SET_ACTIVE_ID](state, { id, sidebarType }) {
state.activeId = id;
state.sidebarType = sidebarType;
@@ -187,8 +200,7 @@ export default {
},
[mutationTypes.MUTATE_ISSUE_SUCCESS]: (state, { issue }) => {
- const issueId = getIdFromGraphQLId(issue.id);
- Vue.set(state.boardItems, issueId, formatIssue({ ...issue, id: issueId }));
+ Vue.set(state.boardItems, issue.id, formatIssue(issue));
},
[mutationTypes.ADD_BOARD_ITEM_TO_LIST]: (
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 264a03ff39d..80c51c966d2 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -31,6 +31,8 @@ export default () => ({
},
selectedProject: {},
error: undefined,
+ iterations: [],
+ iterationsLoading: false,
addColumnForm: {
visible: false,
columnType: ListType.label,
diff --git a/app/assets/javascripts/captcha/init_recaptcha_script.js b/app/assets/javascripts/captcha/init_recaptcha_script.js
index f546eef7d84..28aef22873d 100644
--- a/app/assets/javascripts/captcha/init_recaptcha_script.js
+++ b/app/assets/javascripts/captcha/init_recaptcha_script.js
@@ -1,7 +1,7 @@
// NOTE: This module will be used in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52044
import { memoize } from 'lodash';
-export const RECAPTCHA_API_URL_PREFIX = 'https://www.google.com/recaptcha/api.js';
+export const RECAPTCHA_API_URL_PREFIX = window.gon.recaptcha_api_server_url;
export const RECAPTCHA_ONLOAD_CALLBACK_NAME = 'recaptchaOnloadCallback';
/**
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 03fd600e493..8e527e2bff6 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -337,7 +337,7 @@ export default {
</gl-collapse>
<gl-alert
v-if="containsVariableReference"
- :title="__('Value may contain a variable reference')"
+ :title="__('Value might contain a variable reference')"
:dismissible="false"
variant="warning"
data-testid="contains-variable-reference"
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index f4002537f79..4ebbf05814b 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -26,5 +26,5 @@ export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY';
export const AWS_TOKEN_CONSTANTS = [AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY];
export const CONTAINS_VARIABLE_REFERENCE_MESSAGE = __(
- 'Variable references indicated by %{codeStart}$%{codeEnd} may be expanded. If this is not what you want, consider %{docsLinkStart}using a workaround to prevent expansion%{docsLinkEnd}.',
+ 'Values that contain the %{codeStart}$%{codeEnd} character can be considered a variable reference and expanded. %{docsLinkStart}Learn more.%{docsLinkEnd}',
);
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index c2c035963f4..8dcab55ac61 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -218,14 +218,14 @@ export default class Clusters {
}
setBannerDismissedState(status, isDismissed) {
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (AccessorUtilities.canUseLocalStorage()) {
window.localStorage.setItem(this.clusterBannerDismissedKey, `${status}_${isDismissed}`);
}
}
isBannerDismissed(status) {
let bannerState;
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (AccessorUtilities.canUseLocalStorage()) {
bannerState = window.localStorage.getItem(this.clusterBannerDismissedKey);
}
@@ -233,7 +233,7 @@ export default class Clusters {
}
setClusterNewlyCreated(state) {
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (AccessorUtilities.canUseLocalStorage()) {
window.localStorage.setItem(this.clusterNewlyCreatedKey, Boolean(state));
}
}
@@ -242,7 +242,7 @@ export default class Clusters {
// once this is true, it will always be true for a given page load
if (!this.isNewlyCreated) {
let newlyCreated;
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (AccessorUtilities.canUseLocalStorage()) {
newlyCreated = window.localStorage.getItem(this.clusterNewlyCreatedKey);
}
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
index b9c55409330..0da7be4040f 100644
--- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlModal, GlButton, GlFormInput } from '@gitlab/ui';
import { escape } from 'lodash';
import csrf from '~/lib/utils/csrf';
@@ -141,7 +140,7 @@ export default {
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
</ul>
</div>
- <strong v-html="confirmationTextLabel"></strong>
+ <strong v-html="confirmationTextLabel /* eslint-disable-line vue/no-v-html */"></strong>
<form ref="form" :action="clusterPath" method="post" class="gl-mb-5">
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 8f81d967126..0d1534d20e0 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -205,6 +205,8 @@ export default {
:items="clusters"
:fields="fields"
stacked="md"
+ head-variant="white"
+ thead-class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
class="qa-clusters-table"
data-testid="cluster_list_table"
>
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index 5f24a3c370a..580db871f5f 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,7 +1,6 @@
/* eslint-disable func-names, consistent-return, one-var, no-return-assign */
import $ from 'jquery';
-import 'jquery.waitforimages';
// Width where images must fits in, for 2-up this gets divided by 2
const availWidth = 900;
@@ -16,11 +15,7 @@ export default class ImageFile {
// Load two-up view after images are loaded
// so that we can display the correct width and height information
- const $images = $('.two-up.view img', this.file);
-
- $images.waitForImages(() => {
- this.initView('two-up');
- });
+ this.initView('two-up');
}),
);
}
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 8d88b682df2..2109aecdf03 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { initPipelineCountListener } from './utils';
/**
* Used in:
@@ -12,13 +13,7 @@ export default () => {
if (pipelineTableViewEl) {
// Update MR and Commits tabs
- pipelineTableViewEl.addEventListener('update-pipelines-count', (event) => {
- if (event.detail.pipelineCount) {
- const badge = document.querySelector('.js-pipelines-mr-count');
-
- badge.textContent = event.detail.pipelineCount;
- }
- });
+ initPipelineCountListener(pipelineTableViewEl);
if (pipelineTableViewEl.dataset.disableInitialization === undefined) {
const table = new Vue({
diff --git a/app/assets/javascripts/commit/pipelines/utils.js b/app/assets/javascripts/commit/pipelines/utils.js
new file mode 100644
index 00000000000..52cbe52fa9b
--- /dev/null
+++ b/app/assets/javascripts/commit/pipelines/utils.js
@@ -0,0 +1,11 @@
+export function initPipelineCountListener(el) {
+ if (!el) return;
+
+ el.addEventListener('update-pipelines-count', (event) => {
+ if (event.detail.pipelineCount) {
+ const badge = document.querySelector('.js-pipelines-mr-count');
+
+ badge.textContent = event.detail.pipelineCount;
+ }
+ });
+}
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
index 5f778af1dbb..59066162960 100644
--- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -96,12 +96,23 @@ export default {
}
},
},
+ i18n: {
+ project: __('Project'),
+ privateForkSelected: __(
+ "To protect this issue's confidentiality, a private fork of this project was selected.",
+ ),
+ noForks: __('No forks are available to you.'),
+ forkTheProject: __(
+ `To protect this issue's confidentiality, %{linkStart}fork this project%{linkEnd} and set the fork's visibility to private.`,
+ ),
+ readMore: __('Read more'),
+ },
};
</script>
<template>
<div class="confidential-merge-request-fork-group form-group">
- <label>{{ __('Project') }}</label>
+ <label>{{ $options.i18n.project }}</label>
<div>
<dropdown
v-if="projects.length"
@@ -111,25 +122,13 @@ export default {
/>
<p class="text-muted mt-1 mb-0">
<template v-if="projects.length">
- {{
- __(
- "To protect this issue's confidentiality, a private fork of this project was selected.",
- )
- }}
+ {{ $options.i18n.privateForkSelected }}
</template>
<template v-else>
- {{ __('No forks are available to you.') }}<br />
- <gl-sprintf
- :message="
- __(
- `To protect this issue's confidentiality, %{forkLink} and set the fork's visibility to private.`,
- )
- "
- >
- <template #forkLink>
- <a :href="newForkPath" target="_blank" class="help-link">{{
- __('fork this project')
- }}</a>
+ {{ $options.i18n.noForks }}<br />
+ <gl-sprintf :message="$options.i18n.forkTheProject">
+ <template #link="{ content }">
+ <a :href="newForkPath" target="_blank" class="help-link">{{ content }}</a>
</template>
</gl-sprintf>
</template>
@@ -138,7 +137,7 @@ export default {
class="w-auto p-0 d-inline-block text-primary bg-transparent"
target="_blank"
>
- <span class="sr-only">{{ __('Read more') }}</span>
+ <span class="sr-only">{{ $options.i18n.readMore }}</span>
<gl-icon name="question-o" />
</gl-link>
</p>
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index a372233e543..02ab34447ca 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -100,11 +100,13 @@ export default {
:class="{ 'is-focused': focused }"
>
<top-toolbar ref="toolbar" class="gl-mb-4" />
- <formatting-bubble-menu />
<div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center">
<gl-loading-icon size="sm" />
</div>
- <tiptap-editor-content v-else class="md" :editor="contentEditor.tiptapEditor" />
+ <template v-else>
+ <formatting-bubble-menu />
+ <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" />
+ </template>
</div>
</div>
</content-editor-provider>
diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
index 6c00480b87e..14a553ff30b 100644
--- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
+++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue
@@ -20,7 +20,11 @@ export default {
};
</script>
<template>
- <bubble-menu class="gl-shadow gl-rounded-base" :editor="tiptapEditor">
+ <bubble-menu
+ data-testid="formatting-bubble-menu"
+ class="gl-shadow gl-rounded-base"
+ :editor="tiptapEditor"
+ >
<gl-button-group>
<toolbar-button
data-testid="bold"
diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue
index 3762324a431..5b81e5fddcc 100644
--- a/app/assets/javascripts/content_editor/components/wrappers/image.vue
+++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue
@@ -22,6 +22,7 @@ export default {
<img
data-testid="image"
class="gl-max-w-full gl-h-auto"
+ :title="node.attrs.title"
:class="{ 'gl-opacity-5': node.attrs.uploading }"
:src="node.attrs.src"
/>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
new file mode 100644
index 00000000000..c44e8145982
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue
@@ -0,0 +1,142 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
+import { selectedRect as getSelectedRect } from 'prosemirror-tables';
+import { __ } from '~/locale';
+
+const TABLE_CELL_HEADER = 'th';
+const TABLE_CELL_BODY = 'td';
+
+export default {
+ name: 'TableCellBaseWrapper',
+ components: {
+ NodeViewWrapper,
+ NodeViewContent,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ },
+ props: {
+ cellType: {
+ type: String,
+ validator: (type) => [TABLE_CELL_HEADER, TABLE_CELL_BODY].includes(type),
+ required: true,
+ },
+ editor: {
+ type: Object,
+ required: true,
+ },
+ getPos: {
+ type: Function,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ displayActionsDropdown: false,
+ preventHide: true,
+ selectedRect: null,
+ };
+ },
+ computed: {
+ totalRows() {
+ return this.selectedRect?.map.height;
+ },
+ totalCols() {
+ return this.selectedRect?.map.width;
+ },
+ isTableBodyCell() {
+ return this.cellType === TABLE_CELL_BODY;
+ },
+ },
+ mounted() {
+ this.editor.on('selectionUpdate', this.handleSelectionUpdate);
+ this.handleSelectionUpdate();
+ },
+ beforeDestroy() {
+ this.editor.off('selectionUpdate', this.handleSelectionUpdate);
+ },
+ methods: {
+ handleSelectionUpdate() {
+ const { state } = this.editor;
+ const { $cursor } = state.selection;
+
+ this.displayActionsDropdown = $cursor?.pos - $cursor?.parentOffset - 1 === this.getPos();
+ if (this.displayActionsDropdown) {
+ this.selectedRect = getSelectedRect(state);
+ }
+ },
+ runCommand(command) {
+ this.editor.chain()[command]().run();
+ this.hideDropdown();
+ },
+ handleHide($event) {
+ if (this.preventHide) {
+ $event.preventDefault();
+ }
+ this.preventHide = true;
+ },
+ hideDropdown() {
+ this.preventHide = false;
+ this.$refs.dropdown?.hide();
+ },
+ },
+ i18n: {
+ insertColumnBefore: __('Insert column before'),
+ insertColumnAfter: __('Insert column after'),
+ insertRowBefore: __('Insert row before'),
+ insertRowAfter: __('Insert row after'),
+ deleteRow: __('Delete row'),
+ deleteColumn: __('Delete column'),
+ deleteTable: __('Delete table'),
+ editTableActions: __('Edit table'),
+ },
+};
+</script>
+<template>
+ <node-view-wrapper
+ class="gl-relative gl-padding-5 gl-min-w-10"
+ :as="cellType"
+ @click="hideDropdown"
+ >
+ <span v-if="displayActionsDropdown" class="gl-absolute gl-right-0 gl-top-0">
+ <gl-dropdown
+ ref="dropdown"
+ dropup
+ icon="chevron-down"
+ size="small"
+ category="tertiary"
+ boundary="viewport"
+ no-caret
+ text-sr-only
+ :text="$options.i18n.editTableActions"
+ :popper-opts="{ positionFixed: true }"
+ @hide="handleHide($event)"
+ >
+ <gl-dropdown-item @click="runCommand('addColumnBefore')">
+ {{ $options.i18n.insertColumnBefore }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="runCommand('addColumnAfter')">
+ {{ $options.i18n.insertColumnAfter }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="isTableBodyCell" @click="runCommand('addRowBefore')">
+ {{ $options.i18n.insertRowBefore }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="runCommand('addRowAfter')">
+ {{ $options.i18n.insertRowAfter }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item v-if="totalRows > 2 && isTableBodyCell" @click="runCommand('deleteRow')">
+ {{ $options.i18n.deleteRow }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="totalCols > 1" @click="runCommand('deleteColumn')">
+ {{ $options.i18n.deleteColumn }}
+ </gl-dropdown-item>
+ <gl-dropdown-item @click="runCommand('deleteTable')">
+ {{ $options.i18n.deleteTable }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </span>
+ <node-view-content />
+ </node-view-wrapper>
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue
new file mode 100644
index 00000000000..6b4343dd5b8
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue
@@ -0,0 +1,23 @@
+<script>
+import TableCellBase from './table_cell_base.vue';
+
+export default {
+ name: 'TableCellBody',
+ components: {
+ TableCellBase,
+ },
+ props: {
+ editor: {
+ type: Object,
+ required: true,
+ },
+ getPos: {
+ type: Function,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <table-cell-base cell-type="td" v-bind="$props" />
+</template>
diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue
new file mode 100644
index 00000000000..5f9889374f6
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue
@@ -0,0 +1,23 @@
+<script>
+import TableCellBase from './table_cell_base.vue';
+
+export default {
+ name: 'TableCellHeader',
+ components: {
+ TableCellBase,
+ },
+ props: {
+ editor: {
+ type: Object,
+ required: true,
+ },
+ getPos: {
+ type: Function,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <table-cell-base cell-type="th" v-bind="$props" />
+</template>
diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js
index f277508f628..4af9dc8e405 100644
--- a/app/assets/javascripts/content_editor/constants.js
+++ b/app/assets/javascripts/content_editor/constants.js
@@ -45,3 +45,7 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
export const LOADING_CONTENT_EVENT = 'loadingContent';
export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
export const LOADING_ERROR_EVENT = 'loadingError';
+
+export const PARSE_HTML_PRIORITY_LOWEST = 1;
+export const PARSE_HTML_PRIORITY_DEFAULT = 50;
+export const PARSE_HTML_PRIORITY_HIGHEST = 100;
diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js
new file mode 100644
index 00000000000..8f2ce8feb5d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/content_editor.stories.js
@@ -0,0 +1,27 @@
+import { ContentEditor } from './index';
+
+export default {
+ component: ContentEditor,
+ title: 'Components/Content Editor',
+};
+
+const Template = (_, { argTypes }) => ({
+ components: { ContentEditor },
+ props: Object.keys(argTypes),
+ template: '<content-editor v-bind="$props" @initialized="loadContent" />',
+ methods: {
+ loadContent(contentEditor) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ contentEditor.setSerializedContent('Hello content editor');
+ },
+ },
+});
+
+export const Default = Template.bind({});
+
+Default.args = {
+ renderMarkdown: () => '<p>Hello content editor</p>',
+ uploadsPath: '/uploads/',
+ serializerConfig: {},
+ extensions: [],
+};
diff --git a/app/assets/javascripts/content_editor/extensions/audio.js b/app/assets/javascripts/content_editor/extensions/audio.js
new file mode 100644
index 00000000000..25d4068c93f
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/audio.js
@@ -0,0 +1,9 @@
+import Playable from './playable';
+
+export default Playable.extend({
+ name: 'audio',
+ defaultOptions: {
+ ...Playable.options,
+ mediaType: 'audio',
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js
index 45f53fe230b..4512ead44bc 100644
--- a/app/assets/javascripts/content_editor/extensions/blockquote.js
+++ b/app/assets/javascripts/content_editor/extensions/blockquote.js
@@ -1 +1,33 @@
-export { Blockquote as default } from '@tiptap/extension-blockquote';
+import { Blockquote } from '@tiptap/extension-blockquote';
+import { wrappingInputRule } from 'prosemirror-inputrules';
+import { getParents } from '~/lib/utils/dom_utils';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
+
+export const multilineInputRegex = /^\s*>>>\s$/gm;
+
+export default Blockquote.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+
+ multiline: {
+ default: false,
+ parseHTML: (element) => {
+ const source = getMarkdownSource(element);
+ const parentsIncludeBlockquote = getParents(element).some(
+ (p) => p.nodeName.toLowerCase() === 'blockquote',
+ );
+
+ return source && !source.startsWith('>') && !parentsIncludeBlockquote;
+ },
+ },
+ };
+ },
+
+ addInputRules() {
+ return [
+ ...this.parent?.(),
+ wrappingInputRule(multilineInputRegex, this.type, () => ({ multiline: true })),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js
index 01ead571fe1..8d0faf7a9fe 100644
--- a/app/assets/javascripts/content_editor/extensions/bullet_list.js
+++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js
@@ -1 +1,19 @@
-export { BulletList as default } from '@tiptap/extension-bullet-list';
+import { BulletList } from '@tiptap/extension-bullet-list';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
+
+export default BulletList.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+
+ bullet: {
+ default: '*',
+ parseHTML(element) {
+ const bullet = getMarkdownSource(element)?.charAt(0);
+
+ return '*+-'.includes(bullet) ? bullet : '*';
+ },
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
index c6d32fb8547..25f5837d2a6 100644
--- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
+++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js
@@ -8,11 +8,7 @@ export default CodeBlockLowlight.extend({
return {
language: {
default: null,
- parseHTML: (element) => {
- return {
- language: extractLanguage(element),
- };
- },
+ parseHTML: (element) => extractLanguage(element),
},
class: {
default: 'code highlight js-syntax-highlight',
diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js
new file mode 100644
index 00000000000..957fdede27b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/description_item.js
@@ -0,0 +1,49 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+
+export default Node.create({
+ name: 'descriptionItem',
+ content: 'block+',
+ defining: true,
+
+ addAttributes() {
+ return {
+ isTerm: {
+ default: true,
+ parseHTML: (element) => element.tagName.toLowerCase() === 'dt',
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'dt' }, { tag: 'dd' }];
+ },
+
+ renderHTML({ HTMLAttributes: { isTerm, ...HTMLAttributes } }) {
+ return [
+ 'li',
+ mergeAttributes(HTMLAttributes, { class: isTerm ? 'dl-term' : 'dl-description' }),
+ 0,
+ ];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ Enter: () => {
+ return this.editor.commands.splitListItem('descriptionItem');
+ },
+ Tab: () => {
+ const { isTerm } = this.editor.getAttributes('descriptionItem');
+ if (isTerm)
+ return this.editor.commands.updateAttributes('descriptionItem', { isTerm: !isTerm });
+
+ return false;
+ },
+ 'Shift-Tab': () => {
+ const { isTerm } = this.editor.getAttributes('descriptionItem');
+ if (isTerm) return this.editor.commands.liftListItem('descriptionItem');
+
+ return this.editor.commands.updateAttributes('descriptionItem', { isTerm: true });
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/description_list.js b/app/assets/javascripts/content_editor/extensions/description_list.js
new file mode 100644
index 00000000000..a516dfad2b8
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/description_list.js
@@ -0,0 +1,23 @@
+import { Node, mergeAttributes } from '@tiptap/core';
+import { wrappingInputRule } from 'prosemirror-inputrules';
+
+export const inputRegex = /^\s*(<dl>)$/;
+
+export default Node.create({
+ name: 'descriptionList',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ group: 'block list',
+ content: 'descriptionItem+',
+
+ parseHTML() {
+ return [{ tag: 'dl' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['ul', mergeAttributes(HTMLAttributes, { class: 'dl-content' }), 0];
+ },
+
+ addInputRules() {
+ return [wrappingInputRule(inputRegex, this.type)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js
new file mode 100644
index 00000000000..c70d1700941
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/division.js
@@ -0,0 +1,17 @@
+import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
+
+export default Node.create({
+ name: 'division',
+ content: 'block*',
+ group: 'block',
+ defining: true,
+
+ parseHTML() {
+ return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['div', HTMLAttributes, 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js
index d88b9f92215..de608c3aaa2 100644
--- a/app/assets/javascripts/content_editor/extensions/emoji.js
+++ b/app/assets/javascripts/content_editor/extensions/emoji.js
@@ -17,30 +17,18 @@ export default Node.create({
return {
moji: {
default: null,
- parseHTML: (element) => {
- return {
- moji: element.textContent,
- };
- },
+ parseHTML: (element) => element.textContent,
},
name: {
default: null,
- parseHTML: (element) => {
- return {
- name: element.dataset.name,
- };
- },
+ parseHTML: (element) => element.dataset.name,
},
title: {
default: null,
},
unicodeVersion: {
default: '6.0',
- parseHTML: (element) => {
- return {
- unicodeVersion: element.dataset.unicodeVersion,
- };
- },
+ parseHTML: (element) => element.dataset.unicodeVersion,
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/figure.js b/app/assets/javascripts/content_editor/extensions/figure.js
new file mode 100644
index 00000000000..b2076894412
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/figure.js
@@ -0,0 +1,16 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'figure',
+ content: 'block+',
+ group: 'block',
+ defining: true,
+
+ parseHTML() {
+ return [{ tag: 'figure' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['figure', HTMLAttributes, 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/figure_caption.js b/app/assets/javascripts/content_editor/extensions/figure_caption.js
new file mode 100644
index 00000000000..ffd1b474f03
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/figure_caption.js
@@ -0,0 +1,16 @@
+import { Node } from '@tiptap/core';
+
+export default Node.create({
+ name: 'figureCaption',
+ content: 'inline*',
+ group: 'block',
+ defining: true,
+
+ parseHTML() {
+ return [{ tag: 'figcaption' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return ['figcaption', HTMLAttributes, 0];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js
new file mode 100644
index 00000000000..54adb9efa0c
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/html_marks.js
@@ -0,0 +1,66 @@
+import { Mark, mergeAttributes, markInputRule } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
+import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
+
+const marks = [
+ 'ins',
+ 'abbr',
+ 'bdo',
+ 'cite',
+ 'dfn',
+ 'mark',
+ 'small',
+ 'span',
+ 'time',
+ 'kbd',
+ 'q',
+ 'samp',
+ 'var',
+ 'ruby',
+ 'rp',
+ 'rt',
+];
+
+const attrs = {
+ time: ['datetime'],
+ abbr: ['title'],
+ span: ['dir'],
+ bdo: ['dir'],
+};
+
+export default marks.map((name) =>
+ Mark.create({
+ name,
+
+ inclusive: false,
+
+ defaultOptions: {
+ HTMLAttributes: {},
+ },
+
+ addAttributes() {
+ return (attrs[name] || []).reduce(
+ (acc, attr) => ({
+ ...acc,
+ [attr]: {
+ default: null,
+ parseHTML: (element) => element.getAttribute(attr),
+ },
+ }),
+ {},
+ );
+ },
+
+ parseHTML() {
+ return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
+ },
+
+ addInputRules() {
+ return [markInputRule(markInputRegex(name), this.type, extractMarkAttributesFromMatch)];
+ },
+ }),
+);
diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js
index c9e8dfa4ad9..837fab0585f 100644
--- a/app/assets/javascripts/content_editor/extensions/image.js
+++ b/app/assets/javascripts/content_editor/extensions/image.js
@@ -1,6 +1,7 @@
import { Image } from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import ImageWrapper from '../components/wrappers/image.vue';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
@@ -27,27 +28,27 @@ export default Image.extend({
parseHTML: (element) => {
const img = resolveImageEl(element);
- return {
- src: img.dataset.src || img.getAttribute('src'),
- };
+ return img.dataset.src || img.getAttribute('src');
},
},
canonicalSrc: {
default: null,
+ parseHTML: (element) => element.dataset.canonicalSrc,
+ },
+ alt: {
+ default: null,
parseHTML: (element) => {
- return {
- canonicalSrc: element.dataset.canonicalSrc,
- };
+ const img = resolveImageEl(element);
+
+ return img.getAttribute('alt');
},
},
- alt: {
+ title: {
default: null,
parseHTML: (element) => {
const img = resolveImageEl(element);
- return {
- alt: img.getAttribute('alt'),
- };
+ return img.getAttribute('title');
},
},
};
@@ -55,7 +56,7 @@ export default Image.extend({
parseHTML() {
return [
{
- priority: 100,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
tag: 'a.no-attachment-icon',
},
{
diff --git a/app/assets/javascripts/content_editor/extensions/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js
index 9471d324764..3bd328958df 100644
--- a/app/assets/javascripts/content_editor/extensions/inline_diff.js
+++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js
@@ -14,11 +14,7 @@ export default Mark.create({
return {
type: {
default: 'addition',
- parseHTML: (element) => {
- return {
- type: element.classList.contains('deletion') ? 'deletion' : 'addition',
- };
- },
+ parseHTML: (element) => (element.classList.contains('deletion') ? 'deletion' : 'addition'),
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js
index 53104fe07a3..fc0f38e6935 100644
--- a/app/assets/javascripts/content_editor/extensions/link.js
+++ b/app/assets/javascripts/content_editor/extensions/link.js
@@ -36,19 +36,15 @@ export default Link.extend({
...this.parent?.(),
href: {
default: null,
- parseHTML: (element) => {
- return {
- href: element.getAttribute('href'),
- };
- },
+ parseHTML: (element) => element.getAttribute('href'),
+ },
+ title: {
+ title: null,
+ parseHTML: (element) => element.getAttribute('title'),
},
canonicalSrc: {
default: null,
- parseHTML: (element) => {
- return {
- canonicalSrc: element.dataset.canonicalSrc,
- };
- },
+ parseHTML: (element) => element.dataset.canonicalSrc,
},
};
},
diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js
index 9a79187d9c1..57d5bd6ebf8 100644
--- a/app/assets/javascripts/content_editor/extensions/ordered_list.js
+++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js
@@ -1 +1,15 @@
-export { OrderedList as default } from '@tiptap/extension-ordered-list';
+import { OrderedList } from '@tiptap/extension-ordered-list';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
+
+export default OrderedList.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+
+ parens: {
+ default: false,
+ parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)),
+ },
+ };
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js
new file mode 100644
index 00000000000..0062bc563db
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/playable.js
@@ -0,0 +1,66 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+import { Node } from '@tiptap/core';
+
+const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType);
+
+export default Node.create({
+ group: 'inline',
+ inline: true,
+ draggable: true,
+
+ addAttributes() {
+ return {
+ src: {
+ default: null,
+ parseHTML: (element) => {
+ const playable = queryPlayableElement(element, this.options.mediaType);
+
+ return playable.src;
+ },
+ },
+ canonicalSrc: {
+ default: null,
+ parseHTML: (element) => {
+ const playable = queryPlayableElement(element, this.options.mediaType);
+
+ return playable.dataset.canonicalSrc;
+ },
+ },
+ alt: {
+ default: null,
+ parseHTML: (element) => {
+ const playable = queryPlayableElement(element, this.options.mediaType);
+
+ return playable.dataset.title;
+ },
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: `.${this.options.mediaType}-container`,
+ },
+ ];
+ },
+
+ renderHTML({ node }) {
+ return [
+ 'span',
+ { class: `media-container ${this.options.mediaType}-container` },
+ [
+ this.options.mediaType,
+ {
+ src: node.attrs.src,
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ ...this.extraElementAttrs,
+ },
+ ],
+ ['a', { href: node.attrs.src }, node.attrs.alt],
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
index 5f4484af9c8..5e459e65de2 100644
--- a/app/assets/javascripts/content_editor/extensions/reference.js
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -1,4 +1,10 @@
import { Node } from '@tiptap/core';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+
+const getAnchor = (element) => {
+ if (element.nodeName === 'A') return element;
+ return element.querySelector('a');
+};
export default Node.create({
name: 'reference',
@@ -13,43 +19,23 @@ export default Node.create({
return {
className: {
default: null,
- parseHTML: (element) => {
- return {
- className: element.className,
- };
- },
+ parseHTML: (element) => getAnchor(element).className,
},
referenceType: {
default: null,
- parseHTML: (element) => {
- return {
- referenceType: element.dataset.referenceType,
- };
- },
+ parseHTML: (element) => getAnchor(element).dataset.referenceType,
},
originalText: {
default: null,
- parseHTML: (element) => {
- return {
- originalText: element.dataset.original,
- };
- },
+ parseHTML: (element) => getAnchor(element).dataset.original,
},
href: {
default: null,
- parseHTML: (element) => {
- return {
- href: element.getAttribute('href'),
- };
- },
+ parseHTML: (element) => getAnchor(element).getAttribute('href'),
},
text: {
default: null,
- parseHTML: (element) => {
- return {
- text: element.textContent,
- };
- },
+ parseHTML: (element) => getAnchor(element).textContent,
},
};
},
@@ -58,7 +44,10 @@ export default Node.create({
return [
{
tag: 'a.gfm:not([data-link=true])',
- priority: 51,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
+ },
+ {
+ tag: 'span.gl-label',
},
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/subscript.js b/app/assets/javascripts/content_editor/extensions/subscript.js
index 4bf89796efe..d0766f42308 100644
--- a/app/assets/javascripts/content_editor/extensions/subscript.js
+++ b/app/assets/javascripts/content_editor/extensions/subscript.js
@@ -1 +1,9 @@
-export { Subscript as default } from '@tiptap/extension-subscript';
+import { markInputRule } from '@tiptap/core';
+import { Subscript } from '@tiptap/extension-subscript';
+import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
+
+export default Subscript.extend({
+ addInputRules() {
+ return [markInputRule(markInputRegex('sub'), this.type, extractMarkAttributesFromMatch)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/superscript.js b/app/assets/javascripts/content_editor/extensions/superscript.js
index 3eb7d86d90d..6cd814977ea 100644
--- a/app/assets/javascripts/content_editor/extensions/superscript.js
+++ b/app/assets/javascripts/content_editor/extensions/superscript.js
@@ -1 +1,9 @@
-export { Superscript as default } from '@tiptap/extension-superscript';
+import { markInputRule } from '@tiptap/core';
+import { Superscript } from '@tiptap/extension-superscript';
+import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
+
+export default Superscript.extend({
+ addInputRules() {
+ return [markInputRule(markInputRegex('sup'), this.type, extractMarkAttributesFromMatch)];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js
index 5bdc39231a1..befc33e669f 100644
--- a/app/assets/javascripts/content_editor/extensions/table_cell.js
+++ b/app/assets/javascripts/content_editor/extensions/table_cell.js
@@ -1,5 +1,12 @@
import { TableCell } from '@tiptap/extension-table-cell';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue';
+import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableCell.extend({
- content: 'inline*',
+ content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
+
+ addNodeView() {
+ return VueNodeViewRenderer(TableCellBodyWrapper);
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js
index 23509706e4b..829b06fc14b 100644
--- a/app/assets/javascripts/content_editor/extensions/table_header.js
+++ b/app/assets/javascripts/content_editor/extensions/table_header.js
@@ -1,5 +1,11 @@
import { TableHeader } from '@tiptap/extension-table-header';
+import { VueNodeViewRenderer } from '@tiptap/vue-2';
+import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue';
+import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableHeader.extend({
- content: 'inline*',
+ content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
+ addNodeView() {
+ return VueNodeViewRenderer(TableCellHeaderWrapper);
+ },
});
diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js
index 6163c0e043b..9b050edcb28 100644
--- a/app/assets/javascripts/content_editor/extensions/task_item.js
+++ b/app/assets/javascripts/content_editor/extensions/task_item.js
@@ -1,4 +1,5 @@
import { TaskItem } from '@tiptap/extension-task-item';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default TaskItem.extend({
defaultOptions: {
@@ -12,7 +13,8 @@ export default TaskItem.extend({
default: false,
parseHTML: (element) => {
const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox');
- return { checked: checkbox?.checked };
+
+ return checkbox?.checked;
},
renderHTML: (attributes) => ({
'data-checked': attributes.checked,
@@ -26,7 +28,7 @@ export default TaskItem.extend({
return [
{
tag: 'li.task-list-item',
- priority: 100,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];
},
diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js
index b7f6c857bc7..72c6e020102 100644
--- a/app/assets/javascripts/content_editor/extensions/task_list.js
+++ b/app/assets/javascripts/content_editor/extensions/task_list.js
@@ -1,16 +1,24 @@
import { mergeAttributes } from '@tiptap/core';
import { TaskList } from '@tiptap/extension-task-list';
+import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
+import { getMarkdownSource } from '../services/markdown_sourcemap';
export default TaskList.extend({
addAttributes() {
return {
- type: {
- default: 'ul',
- parseHTML: (element) => {
- return {
- type: element.tagName.toLowerCase() === 'ol' ? 'ol' : 'ul',
- };
- },
+ numeric: {
+ default: false,
+ parseHTML: (element) => element.tagName.toLowerCase() === 'ol',
+ },
+ start: {
+ default: 1,
+ parseHTML: (element) =>
+ element.hasAttribute('start') ? parseInt(element.getAttribute('start') || '', 10) : 1,
+ },
+
+ parens: {
+ default: false,
+ parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)),
},
};
},
@@ -19,12 +27,12 @@ export default TaskList.extend({
return [
{
tag: '.task-list',
- priority: 100,
+ priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];
},
- renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) {
- return [type, mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
+ renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) {
+ return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
},
});
diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js
new file mode 100644
index 00000000000..9923b7c04cd
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/video.js
@@ -0,0 +1,10 @@
+import Playable from './playable';
+
+export default Playable.extend({
+ name: 'video',
+ defaultOptions: {
+ ...Playable.options,
+ mediaType: 'video',
+ extraElementAttrs: { width: '400' },
+ },
+});
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 8997960203a..9b2d4c9a062 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -2,19 +2,26 @@ import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
+import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
+import DescriptionItem from '../extensions/description_item';
+import DescriptionList from '../extensions/description_list';
+import Division from '../extensions/division';
import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor';
import Emoji from '../extensions/emoji';
+import Figure from '../extensions/figure';
+import FigureCaption from '../extensions/figure_caption';
import Gapcursor from '../extensions/gapcursor';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import History from '../extensions/history';
import HorizontalRule from '../extensions/horizontal_rule';
+import HTMLMarks from '../extensions/html_marks';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -34,6 +41,7 @@ import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
+import Video from '../extensions/video';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts';
@@ -62,19 +70,26 @@ export const createContentEditor = ({
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown }),
+ Audio,
Blockquote,
Bold,
BulletList,
Code,
CodeBlockHighlight,
+ DescriptionItem,
+ DescriptionList,
Document,
+ Division,
Dropcursor,
Emoji,
+ Figure,
+ FigureCaption,
Gapcursor,
HardBreak,
Heading,
History,
HorizontalRule,
+ ...HTMLMarks,
Image,
InlineDiff,
Italic,
@@ -94,6 +109,7 @@ export const createContentEditor = ({
TaskItem,
TaskList,
Text,
+ Video,
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
diff --git a/app/assets/javascripts/content_editor/services/feature_flags.js b/app/assets/javascripts/content_editor/services/feature_flags.js
new file mode 100644
index 00000000000..5f7a4595938
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/feature_flags.js
@@ -0,0 +1,3 @@
+export function isBlockTablesFeatureEnabled() {
+ return gon.features?.contentEditorBlockTables;
+}
diff --git a/app/assets/javascripts/content_editor/services/mark_utils.js b/app/assets/javascripts/content_editor/services/mark_utils.js
new file mode 100644
index 00000000000..6ccfed7810a
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/mark_utils.js
@@ -0,0 +1,17 @@
+export const markInputRegex = (tag) =>
+ new RegExp(`(<(${tag})((?: \\w+=".+?")+)?>([^<]+)</${tag}>)$`, 'gm');
+
+export const extractMarkAttributesFromMatch = ([, , , attrsString]) => {
+ const attrRegex = /(\w+)="(.+?)"/g;
+ const attrs = {};
+
+ let key;
+ let value;
+
+ do {
+ [, key, value] = attrRegex.exec(attrsString) || [];
+ if (key) attrs[key] = value;
+ } while (key);
+
+ return attrs;
+};
diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js
index df4d31c3d7f..bc6d98511f9 100644
--- a/app/assets/javascripts/content_editor/services/markdown_serializer.js
+++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js
@@ -3,15 +3,22 @@ import {
defaultMarkdownSerializer,
} from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
+import Audio from '../extensions/audio';
import Blockquote from '../extensions/blockquote';
import Bold from '../extensions/bold';
import BulletList from '../extensions/bullet_list';
import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight';
+import DescriptionItem from '../extensions/description_item';
+import DescriptionList from '../extensions/description_list';
+import Division from '../extensions/division';
import Emoji from '../extensions/emoji';
+import Figure from '../extensions/figure';
+import FigureCaption from '../extensions/figure_caption';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
+import HTMLMarks from '../extensions/html_marks';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
@@ -30,6 +37,20 @@ import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
+import Video from '../extensions/video';
+import {
+ isPlainURL,
+ renderHardBreak,
+ renderTable,
+ renderTableCell,
+ renderTableRow,
+ openTag,
+ closeTag,
+ renderOrderedList,
+ renderImage,
+ renderPlayable,
+ renderHTMLNode,
+} from './serialization_helpers';
const defaultSerializerConfig = {
marks: {
@@ -48,14 +69,15 @@ const defaultSerializerConfig = {
},
},
[Link.name]: {
- open() {
- return '[';
+ open(state, mark, parent, index) {
+ return isPlainURL(mark, parent, index, 1) ? '<' : '[';
},
- close(state, mark) {
+ close(state, mark, parent, index) {
const href = mark.attrs.canonicalSrc || mark.attrs.href;
- return `](${state.esc(href)}${
- mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''
- })`;
+
+ return isPlainURL(mark, parent, index, -1)
+ ? '>'
+ : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
},
},
[Strike.name]: {
@@ -64,9 +86,35 @@ const defaultSerializerConfig = {
mixable: true,
expelEnclosingWhitespace: true,
},
+ ...HTMLMarks.reduce(
+ (acc, { name }) => ({
+ ...acc,
+ [name]: {
+ mixable: true,
+ open(state, node) {
+ return openTag(name, node.attrs);
+ },
+ close: closeTag(name),
+ },
+ }),
+ {},
+ ),
},
+
nodes: {
- [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
+ [Audio.name]: renderPlayable,
+ [Blockquote.name]: (state, node) => {
+ if (node.attrs.multiline) {
+ state.write('>>>');
+ state.ensureNewLine();
+ state.renderContent(node);
+ state.ensureNewLine();
+ state.write('>>>');
+ state.closeBlock(node);
+ } else {
+ state.wrapBlock('> ', null, node, () => state.renderContent(node));
+ }
+ },
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[CodeBlockHighlight.name]: (state, node) => {
state.write(`\`\`\`${node.attrs.language || ''}\n`);
@@ -75,94 +123,47 @@ const defaultSerializerConfig = {
state.write('```');
state.closeBlock(node);
},
+ [Division.name]: renderHTMLNode('div'),
+ [DescriptionList.name]: renderHTMLNode('dl', true),
+ [DescriptionItem.name]: (state, node, parent, index) => {
+ if (index === 1) state.ensureNewLine();
+ renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node);
+ if (index === parent.childCount - 1) state.ensureNewLine();
+ },
[Emoji.name]: (state, node) => {
const { name } = node.attrs;
state.write(`:${name}:`);
},
- [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
+ [Figure.name]: renderHTMLNode('figure'),
+ [FigureCaption.name]: renderHTMLNode('figcaption'),
+ [HardBreak.name]: renderHardBreak,
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
- [Image.name]: (state, node) => {
- const { alt, canonicalSrc, src, title } = node.attrs;
- const quotedTitle = title ? ` ${state.quote(title)}` : '';
-
- state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
- },
+ [Image.name]: renderImage,
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
- [OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
+ [OrderedList.name]: renderOrderedList,
[Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
},
- [Table.name]: (state, node) => {
- state.renderContent(node);
- },
- [TableCell.name]: (state, node) => {
- state.renderInline(node);
- },
- [TableHeader.name]: (state, node) => {
- state.renderInline(node);
- },
- [TableRow.name]: (state, node) => {
- const isHeaderRow = node.child(0).type.name === 'tableHeader';
-
- const renderRow = () => {
- const cellWidths = [];
-
- state.flushClose(1);
-
- state.write('| ');
- node.forEach((cell, _, i) => {
- if (i) state.write(' | ');
-
- const { length } = state.out;
- state.render(cell, node, i);
- cellWidths.push(state.out.length - length);
- });
- state.write(' |');
-
- state.closeBlock(node);
-
- return cellWidths;
- };
-
- const renderHeaderRow = (cellWidths) => {
- state.flushClose(1);
-
- state.write('|');
- node.forEach((cell, _, i) => {
- if (i) state.write('|');
-
- state.write(cell.attrs.align === 'center' ? ':' : '-');
- state.write(state.repeat('-', cellWidths[i]));
- state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
- });
- state.write('|');
-
- state.closeBlock(node);
- };
-
- if (isHeaderRow) {
- renderHeaderRow(renderRow());
- } else {
- renderRow();
- }
- },
+ [Table.name]: renderTable,
+ [TableCell.name]: renderTableCell,
+ [TableHeader.name]: renderTableCell,
+ [TableRow.name]: renderTableRow,
[TaskItem.name]: (state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
state.renderContent(node);
},
[TaskList.name]: (state, node) => {
- if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node);
- else defaultMarkdownSerializer.nodes.ordered_list(state, node);
+ if (node.attrs.numeric) renderOrderedList(state, node);
+ else defaultMarkdownSerializer.nodes.bullet_list(state, node);
},
[Text.name]: defaultMarkdownSerializer.nodes.text,
+ [Video.name]: renderPlayable,
},
};
-const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
-
/**
* A markdown serializer converts arbitrary Markdown content
* into a ProseMirror document and viceversa. To convert Markdown
@@ -175,7 +176,7 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
* that parses the Markdown and converts it into HTML.
* @returns a markdown serializer
*/
-export default ({ render = () => null, serializerConfig }) => ({
+export default ({ render = () => null, serializerConfig = {} } = {}) => ({
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.
@@ -187,15 +188,15 @@ export default ({ render = () => null, serializerConfig }) => ({
deserialize: async ({ schema, content }) => {
const html = await render(content);
- if (!html) {
- return null;
- }
+ if (!html) return null;
const parser = new DOMParser();
- const {
- body: { firstElementChild },
- } = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
- const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
+ const { body } = parser.parseFromString(html, 'text/html');
+
+ // append original source as a comment that nodes can access
+ body.append(document.createComment(content));
+
+ const state = ProseMirrorDOMParser.fromSchema(schema).parse(body);
return state.toJSON();
},
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
new file mode 100644
index 00000000000..a1199589c9b
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -0,0 +1,40 @@
+const getFullSource = (element) => {
+ const commentNode = element.ownerDocument.body.lastChild;
+
+ if (commentNode.nodeName === '#comment') {
+ return commentNode.textContent.split('\n');
+ }
+
+ return [];
+};
+
+const getRangeFromSourcePos = (sourcePos) => {
+ const [start, end] = sourcePos.split('-');
+ const [startRow, startCol] = start.split(':');
+ const [endRow, endCol] = end.split(':');
+
+ return {
+ start: { row: Number(startRow) - 1, col: Number(startCol) - 1 },
+ end: { row: Number(endRow) - 1, col: Number(endCol) - 1 },
+ };
+};
+
+export const getMarkdownSource = (element) => {
+ if (!element.dataset.sourcepos) return undefined;
+
+ const source = getFullSource(element);
+ const range = getRangeFromSourcePos(element.dataset.sourcepos);
+ let elSource = '';
+
+ for (let i = range.start.row; i <= range.end.row; i += 1) {
+ if (i === range.start.row) {
+ elSource += source[i]?.substring(range.start.col);
+ } else if (i === range.end.row) {
+ elSource += `\n${source[i]?.substring(0, range.start.col)}`;
+ } else {
+ elSource += `\n${source[i]}` || '';
+ }
+ }
+
+ return elSource.trim();
+};
diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js
new file mode 100644
index 00000000000..b2327555b45
--- /dev/null
+++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js
@@ -0,0 +1,345 @@
+import { uniq } from 'lodash';
+import { isBlockTablesFeatureEnabled } from './feature_flags';
+
+const defaultAttrs = {
+ td: { colspan: 1, rowspan: 1, colwidth: null },
+ th: { colspan: 1, rowspan: 1, colwidth: null },
+};
+
+const ignoreAttrs = {
+ dd: ['isTerm'],
+ dt: ['isTerm'],
+};
+
+const tableMap = new WeakMap();
+
+// Source taken from
+// prosemirror-markdown/src/to_markdown.js
+export function isPlainURL(link, parent, index, side) {
+ if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false;
+ const content = parent.child(index + (side < 0 ? -1 : 0));
+ if (
+ !content.isText ||
+ content.text !== link.attrs.href ||
+ content.marks[content.marks.length - 1] !== link
+ )
+ return false;
+ if (index === (side < 0 ? 1 : parent.childCount - 1)) return true;
+ const next = parent.child(index + (side < 0 ? -2 : 1));
+ return !link.isInSet(next.marks);
+}
+
+function containsOnlyText(node) {
+ if (node.childCount === 1) {
+ const child = node.child(0);
+ return child.isText && child.marks.length === 0;
+ }
+
+ return false;
+}
+
+function containsParagraphWithOnlyText(cell) {
+ if (cell.childCount === 1) {
+ const child = cell.child(0);
+ if (child.type.name === 'paragraph') {
+ return containsOnlyText(child);
+ }
+ }
+
+ return false;
+}
+
+function getRowsAndCells(table) {
+ const cells = [];
+ const rows = [];
+ table.descendants((n) => {
+ if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') {
+ cells.push(n);
+ return false;
+ }
+
+ if (n.type.name === 'tableRow') {
+ rows.push(n);
+ }
+
+ return true;
+ });
+ return { rows, cells };
+}
+
+function getChildren(node) {
+ const children = [];
+ for (let i = 0; i < node.childCount; i += 1) {
+ children.push(node.child(i));
+ }
+ return children;
+}
+
+function shouldRenderHTMLTable(table) {
+ const { rows, cells } = getRowsAndCells(table);
+
+ const cellChildCount = Math.max(...cells.map((cell) => cell.childCount));
+ const maxColspan = Math.max(...cells.map((cell) => cell.attrs.colspan));
+ const maxRowspan = Math.max(...cells.map((cell) => cell.attrs.rowspan));
+
+ const rowChildren = rows.map((row) => uniq(getChildren(row).map((cell) => cell.type.name)));
+ const cellTypeInFirstRow = rowChildren[0];
+ const cellTypesInOtherRows = uniq(rowChildren.slice(1).map(([type]) => type));
+
+ // if the first row has headers, and there are no headers anywhere else, render markdown table
+ if (
+ !(
+ cellTypeInFirstRow.length === 1 &&
+ cellTypeInFirstRow[0] === 'tableHeader' &&
+ cellTypesInOtherRows.length === 1 &&
+ cellTypesInOtherRows[0] === 'tableCell'
+ )
+ ) {
+ return true;
+ }
+
+ if (cellChildCount === 1 && maxColspan === 1 && maxRowspan === 1) {
+ // if all rows contain only one paragraph each and no rowspan/colspan, render markdown table
+ const children = uniq(cells.map((cell) => cell.child(0).type.name));
+ if (children.length === 1 && children[0] === 'paragraph') {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+function htmlEncode(str = '') {
+ return str
+ .replace(/&/g, '&amp;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;')
+ .replace(/'/g, '&#39;')
+ .replace(/"/g, '&#34;');
+}
+
+export function openTag(tagName, attrs) {
+ let str = `<${tagName}`;
+
+ str += Object.entries(attrs || {})
+ .map(([key, value]) => {
+ if ((ignoreAttrs[tagName] || []).includes(key) || defaultAttrs[tagName]?.[key] === value)
+ return '';
+
+ return ` ${key}="${htmlEncode(value?.toString())}"`;
+ })
+ .join('');
+
+ return `${str}>`;
+}
+
+export function closeTag(tagName) {
+ return `</${tagName}>`;
+}
+
+function isInBlockTable(node) {
+ return tableMap.get(node);
+}
+
+function isInTable(node) {
+ return tableMap.has(node);
+}
+
+function setIsInBlockTable(table, value) {
+ tableMap.set(table, value);
+
+ const { rows, cells } = getRowsAndCells(table);
+ rows.forEach((row) => tableMap.set(row, value));
+ cells.forEach((cell) => {
+ tableMap.set(cell, value);
+ if (cell.childCount && cell.child(0).type.name === 'paragraph')
+ tableMap.set(cell.child(0), value);
+ });
+}
+
+function unsetIsInBlockTable(table) {
+ tableMap.delete(table);
+
+ const { rows, cells } = getRowsAndCells(table);
+ rows.forEach((row) => tableMap.delete(row));
+ cells.forEach((cell) => {
+ tableMap.delete(cell);
+ if (cell.childCount) tableMap.delete(cell.child(0));
+ });
+}
+
+function renderTagOpen(state, tagName, attrs) {
+ state.ensureNewLine();
+ state.write(openTag(tagName, attrs));
+}
+
+function renderTagClose(state, tagName, insertNewline = true) {
+ state.write(closeTag(tagName));
+ if (insertNewline) state.ensureNewLine();
+}
+
+function renderTableHeaderRowAsMarkdown(state, node, cellWidths) {
+ state.flushClose(1);
+
+ state.write('|');
+ node.forEach((cell, _, i) => {
+ if (i) state.write('|');
+
+ state.write(cell.attrs.align === 'center' ? ':' : '-');
+ state.write(state.repeat('-', cellWidths[i]));
+ state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
+ });
+ state.write('|');
+
+ state.closeBlock(node);
+}
+
+function renderTableRowAsMarkdown(state, node, isHeaderRow = false) {
+ const cellWidths = [];
+
+ state.flushClose(1);
+
+ state.write('| ');
+ node.forEach((cell, _, i) => {
+ if (i) state.write(' | ');
+
+ const { length } = state.out;
+ state.render(cell, node, i);
+ cellWidths.push(state.out.length - length);
+ });
+ state.write(' |');
+
+ state.closeBlock(node);
+
+ if (isHeaderRow) renderTableHeaderRowAsMarkdown(state, node, cellWidths);
+}
+
+function renderTableRowAsHTML(state, node) {
+ renderTagOpen(state, 'tr');
+
+ node.forEach((cell, _, i) => {
+ const tag = cell.type.name === 'tableHeader' ? 'th' : 'td';
+
+ renderTagOpen(state, tag, cell.attrs);
+
+ if (!containsParagraphWithOnlyText(cell)) {
+ state.closeBlock(node);
+ state.flushClose();
+ }
+
+ state.render(cell, node, i);
+ state.flushClose(1);
+
+ renderTagClose(state, tag);
+ });
+
+ renderTagClose(state, 'tr');
+}
+
+export function renderContent(state, node, forceRenderInline) {
+ if (node.type.inlineContent) {
+ if (containsOnlyText(node)) {
+ state.renderInline(node);
+ } else {
+ state.closeBlock(node);
+ state.flushClose();
+ state.renderInline(node);
+ state.closeBlock(node);
+ state.flushClose();
+ }
+ } else {
+ const renderInline = forceRenderInline || containsParagraphWithOnlyText(node);
+ if (!renderInline) {
+ state.closeBlock(node);
+ state.flushClose();
+ state.renderContent(node);
+ state.ensureNewLine();
+ } else {
+ state.renderInline(forceRenderInline ? node : node.child(0));
+ }
+ }
+}
+
+export function renderHTMLNode(tagName, forceRenderInline = false) {
+ return (state, node) => {
+ renderTagOpen(state, tagName, node.attrs);
+ renderContent(state, node, forceRenderInline);
+ renderTagClose(state, tagName, false);
+ };
+}
+
+export function renderOrderedList(state, node) {
+ const { parens } = node.attrs;
+ const start = node.attrs.start || 1;
+ const maxW = String(start + node.childCount - 1).length;
+ const space = state.repeat(' ', maxW + 2);
+ const delimiter = parens ? ')' : '.';
+
+ state.renderList(node, space, (i) => {
+ const nStr = String(start + i);
+ return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `;
+ });
+}
+
+export function renderTableCell(state, node) {
+ if (!isBlockTablesFeatureEnabled()) {
+ state.renderInline(node);
+ return;
+ }
+
+ if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) {
+ state.renderInline(node.child(0));
+ } else {
+ state.renderContent(node);
+ }
+}
+
+export function renderTableRow(state, node) {
+ if (isInBlockTable(node)) {
+ renderTableRowAsHTML(state, node);
+ } else {
+ renderTableRowAsMarkdown(state, node, node.child(0).type.name === 'tableHeader');
+ }
+}
+
+export function renderTable(state, node) {
+ if (isBlockTablesFeatureEnabled()) {
+ setIsInBlockTable(node, shouldRenderHTMLTable(node));
+ }
+
+ if (isInBlockTable(node)) renderTagOpen(state, 'table');
+
+ state.renderContent(node);
+
+ if (isInBlockTable(node)) renderTagClose(state, 'table');
+
+ // ensure at least one blank line after any table
+ state.closeBlock(node);
+ state.flushClose();
+
+ if (isBlockTablesFeatureEnabled()) {
+ unsetIsInBlockTable(node);
+ }
+}
+
+export function renderHardBreak(state, node, parent, index) {
+ const br = isInTable(parent) ? '<br>' : '\\\n';
+
+ for (let i = index + 1; i < parent.childCount; i += 1) {
+ if (parent.child(i).type !== node.type) {
+ state.write(br);
+ return;
+ }
+ }
+}
+
+export function renderImage(state, node) {
+ const { alt, canonicalSrc, src, title } = node.attrs;
+ const quotedTitle = title ? ` ${state.quote(title)}` : '';
+
+ state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
+}
+
+export function renderPlayable(state, node) {
+ renderImage(state, node);
+}
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
index 45c886978f1..004c2e26c4e 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
@@ -1,9 +1,7 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlButton, GlFormGroup, GlFormInput, GlIcon, GlLink, GlSprintf, GlAlert } from '@gitlab/ui';
-import { escape } from 'lodash';
import { mapState, mapActions } from 'vuex';
-import { sprintf, s__, __ } from '~/locale';
+import { s__, __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { DEFAULT_REGION } from '../constants';
@@ -38,6 +36,9 @@ export default {
regionHelpText: s__(
'ClusterIntegration|Select the region you want to create the new cluster in. Make sure you have access to this region for your role to be able to authenticate. If no region is selected, we will use %{codeStart}DEFAULT_REGION%{codeEnd}. Learn more about %{linkStart}Regions%{linkEnd}.',
),
+ accountAndExternalIdsHelpText: s__(
+ 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provisioned role, first create one on %{awsLinkStart}Amazon Web Services %{awsLinkEnd} using the above account and external IDs. %{moreInfoStart}More information%{moreInfoEnd}',
+ ),
regionHelpTextDefaultRegion: DEFAULT_REGION,
},
data() {
@@ -56,39 +57,8 @@ export default {
? __('Authenticating')
: s__('ClusterIntegration|Authenticate with AWS');
},
- accountAndExternalIdsHelpText() {
- const escapedUrl = escape(this.accountAndExternalIdsHelpPath);
-
- return sprintf(
- s__(
- 'ClusterIntegration|Create a provision role on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the account and external ID above. %{startMoreInfoLink}More information%{endLink}',
- ),
- {
- startAwsLink:
- '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
- startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
- },
- provisionRoleArnHelpText() {
- const escapedUrl = escape(this.createRoleArnHelpPath);
-
- return sprintf(
- s__(
- 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provisioned role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}',
- ),
- {
- startAwsLink:
- '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
- startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
- externalLinkIcon: this.externalLinkIcon,
- endLink: '</a>',
- },
- false,
- );
+ awsHelpLink() {
+ return 'https://console.aws.amazon.com/iam/home?#roles';
},
},
methods: {
@@ -142,13 +112,41 @@ export default {
</div>
</div>
<div class="col-12 mb-3 mt-n3">
- <p class="form-text text-muted" v-html="accountAndExternalIdsHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.accountAndExternalIdsHelpText">
+ <template #awsLink="{ content }">
+ <gl-link :href="awsHelpLink" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ <template #moreInfo="{ content }">
+ <gl-link :href="accountAndExternalIdsHelpPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
</div>
<div class="form-group">
<label for="eks-provision-role-arn">{{ s__('ClusterIntegration|Provision Role ARN') }}</label>
<gl-form-input id="eks-provision-role-arn" v-model="roleArn" />
- <p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p>
+ <p class="form-text text-muted">
+ <gl-sprintf :message="$options.i18n.accountAndExternalIdsHelpText">
+ <template #awsLink="{ content }">
+ <gl-link :href="awsHelpLink" target="_blank">
+ {{ content }}
+ <gl-icon name="external-link" class="gl-vertical-align-middle" />
+ </gl-link>
+ </template>
+ <template #moreInfo="{ content }">
+ <gl-link :href="accountAndExternalIdsHelpPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
</div>
<gl-form-group :label="$options.i18n.regionInputLabel">
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
index 1c698cc2796..3ed0f050301 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
@@ -1,9 +1,9 @@
export const DEFAULT_REGION = 'us-east-2';
export const KUBERNETES_VERSIONS = [
- { name: '1.15', value: '1.15' },
{ name: '1.16', value: '1.16' },
{ name: '1.17', value: '1.17' },
{ name: '1.18', value: '1.18' },
- { name: '1.19', value: '1.19', default: true },
+ { name: '1.19', value: '1.19' },
+ { name: '1.20', value: '1.20', default: true },
];
diff --git a/app/assets/javascripts/cycle_analytics/components/banner.vue b/app/assets/javascripts/cycle_analytics/components/banner.vue
deleted file mode 100644
index cf4c35ef12b..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/banner.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-<script>
-/* eslint-disable vue/no-v-html */
-import { GlIcon } from '@gitlab/ui';
-import iconCycleAnalyticsSplash from 'icons/_icon_cycle_analytics_splash.svg';
-
-export default {
- components: {
- GlIcon,
- },
- props: {
- documentationLink: {
- type: String,
- required: true,
- },
- },
- computed: {
- iconCycleAnalyticsSplash() {
- return iconCycleAnalyticsSplash;
- },
- },
- methods: {
- dismissOverviewDialog() {
- this.$emit('dismiss-overview-dialog');
- },
- },
-};
-</script>
-<template>
- <div class="landing content-block">
- <button
- :aria-label="__('Dismiss Value Stream Analytics introduction box')"
- class="js-ca-dismiss-button dismiss-button"
- type="button"
- @click="dismissOverviewDialog"
- >
- <gl-icon name="close" />
- </button>
- <div class="svg-container" v-html="iconCycleAnalyticsSplash"></div>
- <div class="inner-content">
- <h4>{{ __('Introducing Value Stream Analytics') }}</h4>
- <p>
- {{
- __(`Value Stream Analytics gives an overview
-of how much time it takes to go from idea to production in your project.`)
- }}
- </p>
- <p>
- <a :href="documentationLink" target="_blank" rel="nofollow" class="btn">
- {{ __('Read more') }}
- </a>
- </p>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index c9ecac6829b..ae78ce33263 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -1,9 +1,10 @@
<script>
-import { GlIcon, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { GlLoadingIcon } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { mapActions, mapState, mapGetters } from 'vuex';
import PathNavigation from '~/cycle_analytics/components/path_navigation.vue';
import StageTable from '~/cycle_analytics/components/stage_table.vue';
+import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue';
import ValueStreamMetrics from '~/cycle_analytics/components/value_stream_metrics.vue';
import { __ } from '~/locale';
import { SUMMARY_METRICS_REQUEST, METRICS_REQUESTS } from '../constants';
@@ -13,11 +14,10 @@ const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
export default {
name: 'CycleAnalytics',
components: {
- GlIcon,
GlLoadingIcon,
- GlSprintf,
PathNavigation,
StageTable,
+ ValueStreamFilters,
ValueStreamMetrics,
},
props: {
@@ -45,11 +45,12 @@ export default {
'selectedStageError',
'stages',
'summary',
- 'daysInPast',
'permissions',
'stageCounts',
'endpoints',
'features',
+ 'createdBefore',
+ 'createdAfter',
]),
...mapGetters(['pathNavigationData', 'filterParams']),
displayStageEvents() {
@@ -98,14 +99,12 @@ export default {
},
},
methods: {
- ...mapActions([
- 'fetchCycleAnalyticsData',
- 'fetchStageData',
- 'setSelectedStage',
- 'setDateRange',
- ]),
- handleDateSelect(daysInPast) {
- this.setDateRange(daysInPast);
+ ...mapActions(['fetchStageData', 'setSelectedStage', 'setDateRange']),
+ onSetDateRange({ startDate, endDate }) {
+ this.setDateRange({
+ createdAfter: new Date(startDate),
+ createdBefore: new Date(endDate),
+ });
},
onSelectStage(stage) {
this.setSelectedStage(stage);
@@ -133,35 +132,22 @@ export default {
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row">
<path-navigation
v-if="displayPathNavigation"
- class="js-path-navigation gl-w-full gl-pb-2"
+ data-testid="vsa-path-navigation"
+ class="gl-w-full gl-pb-2"
:loading="isLoading || isLoadingStage"
:stages="pathNavigationData"
:selected-stage="selectedStage"
@selected="onSelectStage"
/>
- <div class="gl-flex-grow gl-align-self-end">
- <div class="js-ca-dropdown dropdown inline">
- <!-- eslint-disable-next-line @gitlab/vue-no-data-toggle -->
- <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
- <span class="dropdown-label">
- <gl-sprintf :message="$options.i18n.dropdownText">
- <template #days>{{ daysInPast }}</template>
- </gl-sprintf>
- <gl-icon name="chevron-down" class="dropdown-menu-toggle-icon gl-top-3" />
- </span>
- </button>
- <ul class="dropdown-menu dropdown-menu-right">
- <li v-for="days in $options.dayRangeOptions" :key="`day-range-${days}`">
- <a href="#" @click.prevent="handleDateSelect(days)">
- <gl-sprintf :message="$options.i18n.dropdownText">
- <template #days>{{ days }}</template>
- </gl-sprintf>
- </a>
- </li>
- </ul>
- </div>
- </div>
</div>
+ <value-stream-filters
+ :group-id="endpoints.groupId"
+ :group-path="endpoints.groupPath"
+ :has-project-filter="false"
+ :start-date="createdAfter"
+ :end-date="createdBefore"
+ @setDateRange="onSetDateRange"
+ />
<value-stream-metrics
:request-path="endpoints.fullPath"
:request-params="filterParams"
@@ -178,6 +164,7 @@ export default {
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
:pagination="null"
+ :sortable="false"
/>
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_table.vue b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
index 0c47838c773..8a2667a4ab1 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_table.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_table.vue
@@ -23,8 +23,8 @@ import TotalTime from './total_time_component.vue';
const DEFAULT_WORKFLOW_TITLE_PROPERTIES = {
thClass: 'gl-w-half',
key: PAGINATION_SORT_FIELD_END_EVENT,
- sortable: true,
};
+
const WORKFLOW_COLUMN_TITLES = {
issues: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Issues') },
jobs: { ...DEFAULT_WORKFLOW_TITLE_PROPERTIES, label: __('Jobs') },
@@ -84,6 +84,11 @@ export default {
required: false,
default: null,
},
+ sortable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
if (this.pagination) {
@@ -122,9 +127,11 @@ export default {
key: PAGINATION_SORT_FIELD_DURATION,
label: __('Time'),
thClass: 'gl-w-half',
- sortable: true,
},
- ];
+ ].map((field) => ({
+ ...field,
+ sortable: this.sortable,
+ }));
},
prevPage() {
return Math.max(this.pagination.page - 1, 0);
diff --git a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
index 6b1e537dc77..8610dfc2b03 100644
--- a/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
+++ b/app/assets/javascripts/cycle_analytics/components/value_stream_filters.vue
@@ -61,33 +61,38 @@ export default {
<template>
<div class="gl-mt-3 gl-py-2 gl-px-3 bg-gray-light border-top border-bottom">
<filter-bar
- class="js-filter-bar filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none"
+ data-testid="vsa-filter-bar"
+ class="filtered-search-box gl-display-flex gl-mb-2 gl-mr-3 gl-border-none"
:group-path="groupPath"
/>
<div
v-if="hasDateRangeFilter || hasProjectFilter"
class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-justify-content-space-between"
>
- <projects-dropdown-filter
- v-if="hasProjectFilter"
- :key="groupId"
- class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0"
- :group-id="groupId"
- :group-namespace="groupPath"
- :query-params="projectsQueryParams"
- :multi-select="$options.multiProjectSelect"
- :default-projects="selectedProjects"
- @selected="$emit('selectProject', $event)"
- />
- <date-range
- v-if="hasDateRangeFilter"
- :start-date="startDate"
- :end-date="endDate"
- :max-date-range="$options.maxDateRange"
- :include-selected-date="true"
- class="js-daterange-picker"
- @change="$emit('setDateRange', $event)"
- />
+ <div>
+ <projects-dropdown-filter
+ v-if="hasProjectFilter"
+ :key="groupId"
+ class="js-projects-dropdown-filter project-select gl-mb-2 gl-lg-mb-0"
+ :group-id="groupId"
+ :group-namespace="groupPath"
+ :query-params="projectsQueryParams"
+ :multi-select="$options.multiProjectSelect"
+ :default-projects="selectedProjects"
+ @selected="$emit('selectProject', $event)"
+ />
+ </div>
+ <div>
+ <date-range
+ v-if="hasDateRangeFilter"
+ :start-date="startDate"
+ :end-date="endDate"
+ :max-date-range="$options.maxDateRange"
+ :include-selected-date="true"
+ class="js-daterange-picker"
+ @change="$emit('setDateRange', $event)"
+ />
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js
index 3827db4d9b2..620da0104e0 100644
--- a/app/assets/javascripts/cycle_analytics/index.js
+++ b/app/assets/javascripts/cycle_analytics/index.js
@@ -1,7 +1,9 @@
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import CycleAnalytics from './components/base.vue';
+import { DEFAULT_DAYS_TO_DISPLAY } from './constants';
import createStore from './store';
+import { calculateFormattedDayInPast } from './utils';
Vue.use(Translate);
@@ -14,19 +16,29 @@ export default () => {
requestPath,
fullPath,
projectId,
+ groupId,
groupPath,
+ labelsPath,
+ milestonesPath,
} = el.dataset;
+ const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
+
store.dispatch('initializeVsa', {
projectId: parseInt(projectId, 10),
- groupPath,
endpoints: {
requestPath,
fullPath,
+ labelsPath,
+ milestonesPath,
+ groupId: parseInt(groupId, 10),
+ groupPath,
},
features: {
cycleAnalyticsForGroups: Boolean(gon?.licensed_features?.cycleAnalyticsForGroups),
},
+ createdBefore: new Date(now),
+ createdAfter: new Date(past),
});
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index a7a2c8ea9d3..e39cd224199 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -163,6 +163,7 @@ const refetchStageData = (dispatch) => {
dispatch('fetchCycleAnalyticsData'),
dispatch('fetchStageData'),
dispatch('fetchStageMedians'),
+ dispatch('fetchStageCountValues'),
]),
)
.finally(() => dispatch('setLoading', false));
@@ -170,14 +171,24 @@ const refetchStageData = (dispatch) => {
export const setFilters = ({ dispatch }) => refetchStageData(dispatch);
-export const setDateRange = ({ dispatch, commit }, daysInPast) => {
- commit(types.SET_DATE_RANGE, daysInPast);
+export const setDateRange = ({ dispatch, commit }, { createdAfter, createdBefore }) => {
+ commit(types.SET_DATE_RANGE, { createdAfter, createdBefore });
return refetchStageData(dispatch);
};
export const initializeVsa = ({ commit, dispatch }, initialData = {}) => {
commit(types.INITIALIZE_VSA, initialData);
+ const {
+ endpoints: { fullPath, groupPath, milestonesPath = '', labelsPath = '' },
+ } = initialData;
+ dispatch('filters/setEndpoints', {
+ labelsEndpoint: labelsPath,
+ milestonesEndpoint: milestonesPath,
+ groupEndpoint: groupPath,
+ projectEndpoint: fullPath,
+ });
+
return dispatch('setLoading', true)
.then(() => dispatch('fetchValueStreams'))
.finally(() => dispatch('setLoading', false));
diff --git a/app/assets/javascripts/cycle_analytics/store/getters.js b/app/assets/javascripts/cycle_analytics/store/getters.js
index 9faccabcaad..77c285f5ce0 100644
--- a/app/assets/javascripts/cycle_analytics/store/getters.js
+++ b/app/assets/javascripts/cycle_analytics/store/getters.js
@@ -1,5 +1,6 @@
import dateFormat from 'dateformat';
import { dateFormats } from '~/analytics/shared/constants';
+import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { transformStagesForPathNavigation, filterStagesByHiddenStatus } from '../utils';
export const pathNavigationData = ({ stages, medians, stageCounts, selectedStage }) => {
@@ -20,6 +21,21 @@ export const requestParams = (state) => {
return { requestPath: fullPath, valueStreamId, stageId };
};
+const filterBarParams = ({ filters }) => {
+ const {
+ authors: { selected: selectedAuthor },
+ milestones: { selected: selectedMilestone },
+ assignees: { selectedList: selectedAssigneeList },
+ labels: { selectedList: selectedLabelList },
+ } = filters;
+ return filterToQueryObject({
+ milestone_title: selectedMilestone,
+ author_username: selectedAuthor,
+ label_name: selectedLabelList,
+ assignee_username: selectedAssigneeList,
+ });
+};
+
const dateRangeParams = ({ createdAfter, createdBefore }) => ({
created_after: createdAfter ? dateFormat(createdAfter, dateFormats.isoDate) : null,
created_before: createdBefore ? dateFormat(createdBefore, dateFormats.isoDate) : null,
@@ -33,6 +49,7 @@ export const legacyFilterParams = ({ daysInPast }) => {
export const filterParams = (state) => {
return {
+ ...filterBarParams(state),
...dateRangeParams(state),
};
};
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index e41de85c1fa..301e7d95f8c 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -1,14 +1,12 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
-import { formatMedianValues, calculateFormattedDayInPast } from '../utils';
+import { formatMedianValues } from '../utils';
import * as types from './mutation_types';
export default {
- [types.INITIALIZE_VSA](state, { endpoints, features }) {
+ [types.INITIALIZE_VSA](state, { endpoints, features, createdBefore, createdAfter }) {
state.endpoints = endpoints;
- const { now, past } = calculateFormattedDayInPast(DEFAULT_DAYS_TO_DISPLAY);
- state.createdBefore = now;
- state.createdAfter = past;
+ state.createdBefore = createdBefore;
+ state.createdAfter = createdAfter;
state.features = features;
},
[types.SET_LOADING](state, loadingState) {
@@ -20,11 +18,9 @@ export default {
[types.SET_SELECTED_STAGE](state, stage) {
state.selectedStage = stage;
},
- [types.SET_DATE_RANGE](state, daysInPast) {
- state.daysInPast = daysInPast;
- const { now, past } = calculateFormattedDayInPast(daysInPast);
- state.createdBefore = now;
- state.createdAfter = past;
+ [types.SET_DATE_RANGE](state, { createdAfter, createdBefore }) {
+ state.createdBefore = createdBefore;
+ state.createdAfter = createdAfter;
},
[types.REQUEST_VALUE_STREAMS](state) {
state.valueStreams = [];
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index e6da3f609b2..0882db51218 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -1,10 +1,7 @@
-import { DEFAULT_DAYS_TO_DISPLAY } from '../constants';
-
export default () => ({
id: null,
features: {},
endpoints: {},
- daysInPast: DEFAULT_DAYS_TO_DISPLAY,
createdAfter: null,
createdBefore: null,
stages: [],
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
index 8282f1d910a..77767456f76 100644
--- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { GlTable, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { s__ } from '~/locale';
@@ -21,21 +21,42 @@ export default {
key: 'edit',
label: s__('DeployFreeze|Edit'),
},
+ {
+ key: 'delete',
+ label: s__('DeployFreeze|Delete'),
+ },
],
translations: {
addDeployFreeze: s__('DeployFreeze|Add deploy freeze'),
+ deleteDeployFreezeTitle: s__('DeployFreeze|Delete deploy freeze?'),
+ deleteDeployFreezeMessage: s__(
+ 'DeployFreeze|Deploy freeze from %{start} to %{end} in %{timezone} will be removed. Are you sure?',
+ ),
emptyStateText: s__(
'DeployFreeze|No deploy freezes exist for this project. To add one, select %{strongStart}Add deploy freeze%{strongEnd}',
),
},
+ modal: {
+ id: 'deleteFreezePeriodModal',
+ actionPrimary: {
+ text: s__('DeployFreeze|Delete freeze period'),
+ attributes: { variant: 'danger', 'data-testid': 'modal-confirm' },
+ },
+ },
components: {
GlTable,
GlButton,
+ GlModal,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
+ data() {
+ return {
+ freezePeriodToDelete: null,
+ };
+ },
computed: {
...mapState(['freezePeriods']),
tableIsNotEmpty() {
@@ -46,7 +67,14 @@ export default {
this.fetchFreezePeriods();
},
methods: {
- ...mapActions(['fetchFreezePeriods', 'setFreezePeriod']),
+ ...mapActions(['fetchFreezePeriods', 'setFreezePeriod', 'deleteFreezePeriod']),
+ handleDeleteFreezePeriod(freezePeriod) {
+ this.freezePeriodToDelete = freezePeriod;
+ },
+ confirmDeleteFreezePeriod() {
+ this.deleteFreezePeriod(this.freezePeriodToDelete);
+ this.freezePeriodToDelete = null;
+ },
},
};
</script>
@@ -72,6 +100,18 @@ export default {
@click="setFreezePeriod(item)"
/>
</template>
+ <template #cell(delete)="{ item }">
+ <gl-button
+ v-gl-modal="$options.modal.id"
+ category="secondary"
+ variant="danger"
+ icon="remove"
+ :aria-label="$options.modal.actionPrimary.text"
+ :loading="item.isDeleting"
+ data-testid="delete-deploy-freeze"
+ @click="handleDeleteFreezePeriod(item)"
+ />
+ </template>
<template #empty>
<p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
<gl-sprintf :message="$options.translations.emptyStateText">
@@ -90,5 +130,24 @@ export default {
>
{{ $options.translations.addDeployFreeze }}
</gl-button>
+ <gl-modal
+ :title="$options.translations.deleteDeployFreezeTitle"
+ :modal-id="$options.modal.id"
+ :action-primary="$options.modal.actionPrimary"
+ static
+ @primary="confirmDeleteFreezePeriod"
+ >
+ <template v-if="freezePeriodToDelete">
+ <gl-sprintf :message="$options.translations.deleteDeployFreezeMessage">
+ <template #start>
+ <code>{{ freezePeriodToDelete.freezeStart }}</code>
+ </template>
+ <template #end>
+ <code>{{ freezePeriodToDelete.freezeEnd }}</code>
+ </template>
+ <template #timezone>{{ freezePeriodToDelete.cronTimezone.formattedTimezone }}</template>
+ </gl-sprintf>
+ </template>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js
index fed80b46eda..1ac6781a0e3 100644
--- a/app/assets/javascripts/deploy_freeze/store/actions.js
+++ b/app/assets/javascripts/deploy_freeze/store/actions.js
@@ -1,5 +1,6 @@
import Api from '~/api';
import createFlash from '~/flash';
+import { logError } from '~/lib/logger';
import { __ } from '~/locale';
import * as types from './mutation_types';
@@ -52,6 +53,21 @@ export const updateFreezePeriod = (store) =>
}),
);
+export const deleteFreezePeriod = ({ state, commit }, { id }) => {
+ commit(types.REQUEST_DELETE_FREEZE_PERIOD, id);
+
+ return Api.deleteFreezePeriod(state.projectId, id)
+ .then(() => commit(types.RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS, id))
+ .catch((e) => {
+ createFlash({
+ message: __('Error: Unable to delete deploy freeze'),
+ });
+ commit(types.RECEIVE_DELETE_FREEZE_PERIOD_ERROR, id);
+
+ logError(`Unable to delete deploy freeze`, e);
+ });
+};
+
export const fetchFreezePeriods = ({ commit, state }) => {
commit(types.REQUEST_FREEZE_PERIODS);
diff --git a/app/assets/javascripts/deploy_freeze/store/mutation_types.js b/app/assets/javascripts/deploy_freeze/store/mutation_types.js
index 8e6fdfd4443..0fec96e2e4c 100644
--- a/app/assets/javascripts/deploy_freeze/store/mutation_types.js
+++ b/app/assets/javascripts/deploy_freeze/store/mutation_types.js
@@ -10,4 +10,8 @@ export const SET_SELECTED_ID = 'SET_SELECTED_ID';
export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON';
export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON';
+export const REQUEST_DELETE_FREEZE_PERIOD = 'REQUEST_DELETE_FREEZE_PERIOD';
+export const RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS = 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS';
+export const RECEIVE_DELETE_FREEZE_PERIOD_ERROR = 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR';
+
export const RESET_MODAL = 'RESET_MODAL';
diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js
index fdd1ea6e32e..151f7f39f5a 100644
--- a/app/assets/javascripts/deploy_freeze/store/mutations.js
+++ b/app/assets/javascripts/deploy_freeze/store/mutations.js
@@ -1,15 +1,28 @@
+import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { secondsToHours } from '~/lib/utils/datetime_utility';
import * as types from './mutation_types';
-const formatTimezoneName = (freezePeriod, timezoneList) =>
- convertObjectPropsToCamelCase({
+const formatTimezoneName = (freezePeriod, timezoneList) => {
+ const tz = timezoneList.find((timezone) => timezone.identifier === freezePeriod.cron_timezone);
+ return convertObjectPropsToCamelCase({
...freezePeriod,
cron_timezone: {
- formattedTimezone: timezoneList.find((tz) => tz.identifier === freezePeriod.cron_timezone)
- ?.name,
+ formattedTimezone: tz && `[UTC ${secondsToHours(tz.offset)}] ${tz.name}`,
identifier: freezePeriod.cron_timezone,
},
});
+};
+
+const setFreezePeriodIsDeleting = (state, id, isDeleting) => {
+ const freezePeriod = state.freezePeriods.find((f) => f.id === id);
+
+ if (!freezePeriod) {
+ return;
+ }
+
+ Vue.set(freezePeriod, 'isDeleting', isDeleting);
+};
export default {
[types.REQUEST_FREEZE_PERIODS](state) {
@@ -53,6 +66,18 @@ export default {
state.selectedId = id;
},
+ [types.REQUEST_DELETE_FREEZE_PERIOD](state, id) {
+ setFreezePeriodIsDeleting(state, id, true);
+ },
+
+ [types.RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS](state, id) {
+ state.freezePeriods = state.freezePeriods.filter((f) => f.id !== id);
+ },
+
+ [types.RECEIVE_DELETE_FREEZE_PERIOD_ERROR](state, id) {
+ setFreezePeriodIsDeleting(state, id, false);
+ },
+
[types.RESET_MODAL](state) {
state.freezeStartCron = '';
state.freezeEndCron = '';
diff --git a/app/assets/javascripts/deprecated_jquery_dropdown/render.js b/app/assets/javascripts/deprecated_jquery_dropdown/render.js
index 167bc4c286e..37287b9d981 100644
--- a/app/assets/javascripts/deprecated_jquery_dropdown/render.js
+++ b/app/assets/javascripts/deprecated_jquery_dropdown/render.js
@@ -107,7 +107,7 @@ function createLink(data, selected, options, index) {
}
if (options.trackSuggestionClickedLabel) {
- link.setAttribute('data-track-event', 'click_text');
+ link.setAttribute('data-track-action', 'click_text');
link.setAttribute('data-track-label', options.trackSuggestionClickedLabel);
link.setAttribute('data-track-value', index);
link.setAttribute('data-track-property', slugify(data.category || 'no-category'));
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/deprecated_notes.js
index ef51587734d..a42b50edb8a 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/deprecated_notes.js
@@ -7,7 +7,7 @@ class-methods-use-this */
/* global ResolveService */
/*
-old_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app.
+deprecated_notes_spec.js is the spec for the legacy, jQuery notes application. It has nothing to do with the new, fancy Vue notes app.
*/
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
@@ -670,6 +670,10 @@ export default class Notes {
updateNote(noteEntity, $targetNote) {
// Convert returned HTML to a jQuery object so we can modify it further
const $noteEntityEl = $(noteEntity.html);
+ const $noteAvatar = $noteEntityEl.find('.image-diff-avatar-link');
+ const $targetNoteBadge = $targetNote.find('.badge');
+
+ $noteAvatar.append($targetNoteBadge);
this.revertNoteEditForm($targetNote);
$noteEntityEl.renderGFM();
// Find the note's `li` element by ID and replace it with the updated HTML
@@ -1740,5 +1744,3 @@ export default class Notes {
return $closeBtn.text($closeBtn.data('originalText'));
}
}
-
-window.Notes = Notes;
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 336ce714a05..818299e36bd 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -123,7 +123,7 @@ export default {
category="primary"
variant="confirm"
type="submit"
- data-track-event="click_button"
+ data-track-action="click_button"
data-qa-selector="save_comment_button"
@click="$emit('submit-form')"
>
diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue
index c9273f97bed..af3d4453a6a 100644
--- a/app/assets/javascripts/design_management/components/design_scaler.vue
+++ b/app/assets/javascripts/design_management/components/design_scaler.vue
@@ -1,16 +1,21 @@
<script>
import { GlButtonGroup, GlButton } from '@gitlab/ui';
-const SCALE_STEP_SIZE = 0.2;
const DEFAULT_SCALE = 1;
const MIN_SCALE = 1;
-const MAX_SCALE = 2;
+const ZOOM_LEVELS = 5;
export default {
components: {
GlButtonGroup,
GlButton,
},
+ props: {
+ maxScale: {
+ type: Number,
+ required: true,
+ },
+ },
data() {
return {
scale: DEFAULT_SCALE,
@@ -24,7 +29,10 @@ export default {
return this.scale === DEFAULT_SCALE;
},
disableIncrease() {
- return this.scale >= MAX_SCALE;
+ return this.scale >= this.maxScale;
+ },
+ stepSize() {
+ return (this.maxScale - MIN_SCALE) / ZOOM_LEVELS;
},
},
methods: {
@@ -37,10 +45,10 @@ export default {
this.$emit('scale', this.scale);
},
incrementScale() {
- this.setScale(this.scale + SCALE_STEP_SIZE);
+ this.setScale(Math.min(this.scale + this.stepSize, this.maxScale));
},
decrementScale() {
- this.setScale(this.scale - SCALE_STEP_SIZE);
+ this.setScale(Math.max(this.scale - this.stepSize, MIN_SCALE));
},
resetScale() {
this.setScale(DEFAULT_SCALE);
diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue
index efa1ef2107a..ced76eb4843 100644
--- a/app/assets/javascripts/design_management/components/design_sidebar.vue
+++ b/app/assets/javascripts/design_management/components/design_sidebar.vue
@@ -202,7 +202,7 @@ export default {
data-testid="resolved-discussion"
@error="$emit('onDesignDiscussionError', $event)"
@updateNoteError="$emit('updateNoteError', $event)"
- @openForm="updateDiscussionWithOpenForm"
+ @open-form="updateDiscussionWithOpenForm"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
</gl-collapse>
diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue
index 8ab94cd2c4b..5354c7756f5 100644
--- a/app/assets/javascripts/design_management/components/image.vue
+++ b/app/assets/javascripts/design_management/components/image.vue
@@ -57,6 +57,7 @@ export default {
methods: {
onImgLoad() {
requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
+ requestIdleCallback(this.setImageNaturalScale, { timeout: 1000 });
performanceMarkAndMeasure({
measures: [
{
@@ -79,6 +80,27 @@ export default {
};
this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
},
+ setImageNaturalScale() {
+ const { contentImg } = this.$refs;
+
+ if (!contentImg) {
+ return;
+ }
+
+ const { naturalHeight, naturalWidth } = contentImg;
+
+ // In case image 404s
+ if (naturalHeight === 0 || naturalWidth === 0) {
+ return;
+ }
+
+ const { height, width } = this.baseImageSize;
+
+ this.$parent.$emit(
+ 'setMaxScale',
+ Math.round(((height + width) / (naturalHeight + naturalWidth)) * 100) / 100,
+ );
+ },
onResize({ width, height }) {
this.$emit('resize', { width, height });
},
diff --git a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
index 7eb40b12f51..b715633a9f2 100644
--- a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
+++ b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
@@ -1,4 +1,11 @@
fragment VersionListItem on DesignVersion {
id
sha
+ createdAt
+ author {
+ __typename
+ id
+ name
+ avatarUrl
+ }
}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
index 84aeb374351..111f5ac18a7 100644
--- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
+++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql
@@ -1,13 +1,15 @@
#import "../fragments/design.fragment.graphql"
+#import "../fragments/version.fragment.graphql"
mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
designs {
...DesignItem
versions {
+ __typename
nodes {
- id
- sha
+ __typename
+ ...VersionListItem
}
}
}
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 48ee7068809..38ea5406c02 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -46,6 +46,7 @@ import {
import { trackDesignDetailView, servicePingDesignDetailView } from '../../utils/tracking';
const DEFAULT_SCALE = 1;
+const DEFAULT_MAX_SCALE = 2;
export default {
components: {
@@ -96,6 +97,7 @@ export default {
scale: DEFAULT_SCALE,
resolvedDiscussionsExpanded: false,
prevCurrentUserTodos: null,
+ maxScale: DEFAULT_MAX_SCALE,
};
},
apollo: {
@@ -309,9 +311,7 @@ export default {
this.isLatestVersion,
);
- if (this.glFeatures.usageDataDesignAction) {
- servicePingDesignDetailView();
- }
+ servicePingDesignDetailView();
},
updateActiveDiscussion(id, source = ACTIVE_DISCUSSION_SOURCE_TYPES.discussion) {
this.$apollo.mutate({
@@ -330,6 +330,9 @@ export default {
toggleResolvedComments() {
this.resolvedDiscussionsExpanded = !this.resolvedDiscussionsExpanded;
},
+ setMaxScale(event) {
+ this.maxScale = 1 / event;
+ },
},
createImageDiffNoteMutation,
DESIGNS_ROUTE_NAME,
@@ -378,12 +381,13 @@ export default {
@openCommentForm="openCommentForm"
@closeCommentForm="closeCommentForm"
@moveNote="onMoveNote"
+ @setMaxScale="setMaxScale"
/>
<div
class="design-scaler-wrapper gl-absolute gl-mb-6 gl-display-flex gl-justify-content-center gl-align-items-center"
>
- <design-scaler @scale="scale = $event" />
+ <design-scaler :max-scale="maxScale" @scale="scale = $event" />
</div>
</div>
<design-sidebar
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
index ad557f64ce4..e66ae822a34 100644
--- a/app/assets/javascripts/design_management/pages/index.vue
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -140,6 +140,9 @@ export default {
this.$el.scrollIntoView();
}
},
+ beforeDestroy() {
+ document.removeEventListener('paste', this.onDesignPaste);
+ },
methods: {
resetFilesToBeSaved() {
this.filesToBeSaved = [];
diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js
index 05b220801f2..7470f3d259b 100644
--- a/app/assets/javascripts/design_management/utils/design_management_utils.js
+++ b/app/assets/javascripts/design_management/utils/design_management_utils.js
@@ -85,6 +85,13 @@ export const designUploadOptimisticResponse = (files) => {
__typename: 'DesignVersion',
id: -uniqueId(),
sha: -uniqueId(),
+ createdAt: '',
+ author: {
+ __typename: 'UserCore',
+ id: -uniqueId(),
+ name: '',
+ avatarUrl: '',
+ },
},
},
}));
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index e7b2c814bb3..afee7e81791 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -1,3 +1,4 @@
+/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { __, s__, n__, sprintf } from '~/locale';
export const ADD_DISCUSSION_COMMENT_ERROR = s__(
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index d03b5cbc26b..a2ea42e963c 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -1,9 +1,8 @@
<script>
-import { GlLoadingIcon, GlPagination, GlSprintf } from '@gitlab/ui';
+import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Mousetrap from 'mousetrap';
import { mapState, mapGetters, mapActions } from 'vuex';
-import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller';
import api from '~/api';
import {
keysFor,
@@ -47,7 +46,6 @@ import {
import diffsEventHub from '../event_hub';
import { reviewStatuses } from '../utils/file_reviews';
import { diffsApp } from '../utils/performance';
-import { fileByFile } from '../utils/preferences';
import { queueRedisHllEvents } from '../utils/queue_events';
import CollapsedFilesWarning from './collapsed_files_warning.vue';
import CommitWidget from './commit_widget.vue';
@@ -55,13 +53,18 @@ import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
import NoChanges from './no_changes.vue';
-import PreRenderer from './pre_renderer.vue';
import TreeList from './tree_list.vue';
-import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync';
export default {
name: 'DiffsApp',
components: {
+ DynamicScroller: () =>
+ import('vendor/vue-virtual-scroller').then(({ DynamicScroller }) => DynamicScroller),
+ DynamicScrollerItem: () =>
+ import('vendor/vue-virtual-scroller').then(({ DynamicScrollerItem }) => DynamicScrollerItem),
+ PreRenderer: () => import('./pre_renderer.vue').then((PreRenderer) => PreRenderer),
+ VirtualScrollerScrollSync: () =>
+ import('./virtual_scroller_scroll_sync').then((VSSSync) => VSSSync),
CompareVersions,
DiffFile,
NoChanges,
@@ -73,11 +76,8 @@ export default {
PanelResizer,
GlPagination,
GlSprintf,
- DynamicScroller,
- DynamicScrollerItem,
- PreRenderer,
- VirtualScrollerScrollSync,
MrWidgetHowToMergeModal,
+ GlAlert,
},
alerts: {
ALERT_OVERFLOW_HIDDEN,
@@ -189,25 +189,24 @@ export default {
treeWidth,
diffFilesLength: 0,
virtualScrollCurrentIndex: -1,
+ subscribedToVirtualScrollingEvents: false,
};
},
computed: {
- ...mapState({
- isLoading: (state) => state.diffs.isLoading,
- isBatchLoading: (state) => state.diffs.isBatchLoading,
- diffFiles: (state) => state.diffs.diffFiles,
- diffViewType: (state) => state.diffs.diffViewType,
- commit: (state) => state.diffs.commit,
- renderOverflowWarning: (state) => state.diffs.renderOverflowWarning,
- numTotalFiles: (state) => state.diffs.realSize,
- numVisibleFiles: (state) => state.diffs.size,
- plainDiffPath: (state) => state.diffs.plainDiffPath,
- emailPatchPath: (state) => state.diffs.emailPatchPath,
- retrievingBatches: (state) => state.diffs.retrievingBatches,
+ ...mapState('diffs', {
+ numTotalFiles: 'realSize',
+ numVisibleFiles: 'size',
}),
...mapState('diffs', [
'showTreeList',
'isLoading',
+ 'diffFiles',
+ 'diffViewType',
+ 'commit',
+ 'renderOverflowWarning',
+ 'plainDiffPath',
+ 'emailPatchPath',
+ 'retrievingBatches',
'startVersion',
'latestDiff',
'currentDiffFileId',
@@ -227,8 +226,9 @@ export default {
'isParallelView',
'currentDiffIndex',
'isVirtualScrollingEnabled',
+ 'isBatchLoading',
+ 'isBatchLoadingError',
]),
- ...mapGetters('batchComments', ['draftsCount']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
diffs() {
if (!this.viewDiffsFileByFile) {
@@ -316,6 +316,7 @@ export default {
}
this.adjustView();
+ this.subscribeToVirtualScrollingEvents();
},
isLoading: 'adjustView',
renderFileTree: 'adjustView',
@@ -330,7 +331,7 @@ export default {
projectPath: this.projectPath,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
- viewDiffsFileByFile: fileByFile(this.fileByFileUserPreference),
+ viewDiffsFileByFile: this.fileByFileUserPreference || false,
defaultSuggestionCommitMessage: this.defaultSuggestionCommitMessage,
mrReviews: this.rehydratedMrReviews,
});
@@ -349,11 +350,6 @@ export default {
this.setHighlightedRow(id.split('diff-content').pop().slice(1));
}
- if (window.gon?.features?.diffsVirtualScrolling) {
- diffsEventHub.$on('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
- diffsEventHub.$on('scrollToIndex', this.scrollVirtualScrollerToIndex);
- }
-
if (window.gon?.features?.diffSettingsUsageData) {
const events = [];
@@ -383,6 +379,8 @@ export default {
queueRedisHllEvents(events);
}
+
+ this.subscribeToVirtualScrollingEvents();
},
beforeCreate() {
diffsApp.instrument();
@@ -611,6 +609,21 @@ export default {
}
}
},
+ subscribeToVirtualScrollingEvents() {
+ if (
+ window.gon?.features?.diffsVirtualScrolling &&
+ this.shouldShow &&
+ !this.subscribedToVirtualScrollingEvents
+ ) {
+ diffsEventHub.$on('scrollToFileHash', this.scrollVirtualScrollerToFileHash);
+ diffsEventHub.$on('scrollToIndex', this.scrollVirtualScrollerToIndex);
+
+ this.subscribedToVirtualScrollingEvents = true;
+ }
+ },
+ reloadPage() {
+ window.location.reload();
+ },
},
minTreeWidth: MIN_TREE_WIDTH,
maxTreeWidth: MAX_TREE_WIDTH,
@@ -629,17 +642,19 @@ export default {
:diff-files-count-text="numTotalFiles"
/>
- <hidden-files-warning
- v-if="visibleWarning == $options.alerts.ALERT_OVERFLOW_HIDDEN"
- :visible="numVisibleFiles"
- :total="numTotalFiles"
- :plain-diff-path="plainDiffPath"
- :email-patch-path="emailPatchPath"
- />
- <collapsed-files-warning
- v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES"
- :limited="isLimitedContainer"
- />
+ <template v-if="!isBatchLoadingError">
+ <hidden-files-warning
+ v-if="visibleWarning == $options.alerts.ALERT_OVERFLOW_HIDDEN"
+ :visible="numVisibleFiles"
+ :total="numTotalFiles"
+ :plain-diff-path="plainDiffPath"
+ :email-patch-path="emailPatchPath"
+ />
+ <collapsed-files-warning
+ v-if="visibleWarning == $options.alerts.ALERT_COLLAPSED_FILES"
+ :limited="isLimitedContainer"
+ />
+ </template>
<div
:data-can-create-note="getNoteableData.current_user.can_create_note"
@@ -648,7 +663,6 @@ export default {
<div
v-if="renderFileTree"
:style="{ width: `${treeWidth}px` }"
- :class="{ 'review-bar-visible': draftsCount > 0 }"
class="diff-tree-list js-diff-tree-list px-3 pr-md-0"
>
<panel-resizer
@@ -668,11 +682,21 @@ export default {
}"
>
<commit-widget v-if="commit" :commit="commit" :collapsible="false" />
- <div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div>
+ <gl-alert
+ v-if="isBatchLoadingError"
+ variant="danger"
+ :dismissible="false"
+ :primary-button-text="__('Reload page')"
+ @primaryAction="reloadPage"
+ >
+ {{ __("Error: Couldn't load some or all of the changes.") }}
+ </gl-alert>
+ <div v-if="isBatchLoading && !isBatchLoadingError" class="loading">
+ <gl-loading-icon size="lg" />
+ </div>
<template v-else-if="renderDiffFiles">
<dynamic-scroller
v-if="isVirtualScrollingEnabled"
- ref="virtualScroller"
:items="diffs"
:min-item-size="70"
:buffer="1000"
@@ -745,7 +769,10 @@ export default {
</div>
<gl-loading-icon v-else-if="retrievingBatches" size="lg" />
</template>
- <no-changes v-else :changes-empty-state-illustration="changesEmptyStateIllustration" />
+ <no-changes
+ v-else-if="!isBatchLoadingError"
+ :changes-empty-state-illustration="changesEmptyStateIllustration"
+ />
</div>
</div>
<mr-widget-how-to-merge-modal
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 820c64a9502..4435a533591 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
@@ -100,7 +99,10 @@ export default {
<div
class="commit-actions flex-row d-none d-sm-flex align-items-start flex-wrap justify-content-end"
>
- <div v-if="commit.signature_html" v-html="commit.signature_html"></div>
+ <div
+ v-if="commit.signature_html"
+ v-html="commit.signature_html /* eslint-disable-line vue/no-v-html */"
+ ></div>
<commit-pipeline-status
v-if="commit.pipeline_status_path"
:endpoint="commit.pipeline_status_path"
@@ -142,7 +144,7 @@ export default {
<a
:href="commit.commit_url"
class="commit-row-message item-title"
- v-html="commit.title_html"
+ v-html="commit.title_html /* eslint-disable-line vue/no-v-html */"
></a>
<span class="commit-row-message d-block d-sm-none">&middot; {{ commit.short_id }}</span>
@@ -174,7 +176,7 @@ export default {
v-if="commit.description_html"
:class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }"
class="commit-row-description gl-mb-3 gl-text-body"
- v-html="commitDescription"
+ v-html="commitDescription /* eslint-disable-line vue/no-v-html */"
></pre>
</div>
</li>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 933891d698c..d09cc064b2c 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -170,10 +170,7 @@ export default {
return !this.isCollapsed && !this.isFileTooLarge;
},
showLocalFileReviews() {
- const loggedIn = Boolean(gon.current_user_id);
- const featureOn = this.glFeatures.localFileReviews;
-
- return loggedIn && featureOn;
+ return Boolean(gon.current_user_id);
},
codequalityDiffForFile() {
return this.codequalityDiff?.files?.[this.file.file_path] || [];
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 667b8745f7b..4bcb99424db 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -341,7 +341,7 @@ export default {
:gfm="gfmCopyText"
data-testid="diff-file-copy-clipboard"
category="tertiary"
- data-track-event="click_copy_file_button"
+ data-track-action="click_copy_file_button"
data-track-label="diff_copy_file_path_button"
data-track-property="diff_copy_file"
/>
@@ -382,7 +382,7 @@ export default {
:title="externalUrlLabel"
:aria-label="externalUrlLabel"
target="_blank"
- data-track-event="click_toggle_external_button"
+ data-track-action="click_toggle_external_button"
data-track-label="diff_toggle_external_button"
data-track-property="diff_toggle_external"
icon="external-link"
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index db3ad074d2f..737c4d8f33c 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { memoize } from 'lodash';
import { isLoggedIn } from '~/lib/utils/common_utils';
import {
@@ -268,7 +267,7 @@ export default {
]"
class="diff-td line_content with-coverage left-side"
data-testid="left-content"
- v-html="$options.lineContent(props.line.left)"
+ v-html="$options.lineContent(props.line.left) /* eslint-disable-line vue/no-v-html */"
></div>
</template>
<template
@@ -390,7 +389,7 @@ export default {
},
]"
class="diff-td line_content with-coverage right-side parallel"
- v-html="$options.lineContent(props.line.right)"
+ v-html="$options.lineContent(props.line.right) /* eslint-disable-line vue/no-v-html */"
></div>
</template>
<template v-else>
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 5cf242b4ddd..64ded1ca8ca 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -133,7 +133,10 @@ export default {
<template>
<div
- :class="[$options.userColorScheme, { inline, 'with-codequality': hasCodequalityChanges }]"
+ :class="[
+ $options.userColorScheme,
+ { 'inline-diff-view': inline, 'with-codequality': hasCodequalityChanges },
+ ]"
:data-commit-id="commitId"
class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file"
@mousedown="handleParallelLineMouseDown"
diff --git a/app/assets/javascripts/diffs/components/pre_renderer.vue b/app/assets/javascripts/diffs/components/pre_renderer.vue
index c357aa2d924..e4320c40d2c 100644
--- a/app/assets/javascripts/diffs/components/pre_renderer.vue
+++ b/app/assets/javascripts/diffs/components/pre_renderer.vue
@@ -17,7 +17,6 @@ export default {
},
mounted() {
this.width = this.$el.parentNode.offsetWidth;
- window.test = this;
this.$_itemsWithSizeWatcher = this.$watch('vscrollParent.itemsWithSize', async () => {
await this.$nextTick();
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 8dda5eadb16..93961b07e2e 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -72,11 +72,6 @@ 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';
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index bddc28c4758..1b1ab59b2b4 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -3,7 +3,6 @@ import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
-import FindFile from '~/vue_shared/components/file_finder/index.vue';
import eventHub from '../notes/event_hub';
import diffsApp from './components/app.vue';
@@ -12,51 +11,7 @@ import { getReviewsForMergeRequest } from './utils/file_reviews';
import { getDerivedMergeRequestInformation } from './utils/merge_request';
export default function initDiffsApp(store) {
- const fileFinderEl = document.getElementById('js-diff-file-finder');
-
- if (fileFinderEl) {
- // eslint-disable-next-line no-new
- new Vue({
- el: fileFinderEl,
- store,
- computed: {
- ...mapState('diffs', ['fileFinderVisible', 'isLoading']),
- ...mapGetters('diffs', ['flatBlobsList']),
- },
- watch: {
- fileFinderVisible(newVal, oldVal) {
- if (newVal && !oldVal && !this.flatBlobsList.length) {
- eventHub.$emit('fetchDiffData');
- }
- },
- },
- methods: {
- ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']),
- openFile(file) {
- window.mrTabs.tabShown('diffs');
- this.scrollToFile(file.path);
- },
- },
- render(createElement) {
- return createElement(FindFile, {
- props: {
- files: this.flatBlobsList,
- visible: this.fileFinderVisible,
- loading: this.isLoading,
- showDiffStats: true,
- clearSearchOnClose: false,
- },
- on: {
- toggle: this.toggleFileFinder,
- click: this.openFile,
- },
- class: ['diff-file-finder'],
- });
- },
- });
- }
-
- return new Vue({
+ const vm = new Vue({
el: '#js-diffs-app',
name: 'MergeRequestDiffs',
components: {
@@ -157,4 +112,53 @@ export default function initDiffsApp(store) {
});
},
});
+
+ const fileFinderEl = document.getElementById('js-diff-file-finder');
+
+ if (fileFinderEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: fileFinderEl,
+ store,
+ components: {
+ FindFile: () => import('~/vue_shared/components/file_finder/index.vue'),
+ },
+ computed: {
+ ...mapState('diffs', ['fileFinderVisible', 'isLoading']),
+ ...mapGetters('diffs', ['flatBlobsList']),
+ },
+ watch: {
+ fileFinderVisible(newVal, oldVal) {
+ if (newVal && !oldVal && !this.flatBlobsList.length) {
+ eventHub.$emit('fetchDiffData');
+ }
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']),
+ openFile(file) {
+ window.mrTabs.tabShown('diffs');
+ this.scrollToFile(file.path);
+ },
+ },
+ render(createElement) {
+ return createElement('find-file', {
+ props: {
+ files: this.flatBlobsList,
+ visible: this.fileFinderVisible,
+ loading: this.isLoading,
+ showDiffStats: true,
+ clearSearchOnClose: false,
+ },
+ on: {
+ toggle: this.toggleFileFinder,
+ click: this.openFile,
+ },
+ class: ['diff-file-finder'],
+ });
+ },
+ });
+ }
+
+ return vm;
}
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index f7bdbe94bac..5c94c6b803b 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -29,9 +29,6 @@ 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,
TRACKING_CLICK_DIFF_VIEW_SETTING,
TRACKING_DIFF_VIEW_INLINE,
TRACKING_DIFF_VIEW_PARALLEL,
@@ -104,7 +101,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
let totalLoaded = 0;
let scrolledVirtualScroller = false;
- commit(types.SET_BATCH_LOADING, true);
+ commit(types.SET_BATCH_LOADING_STATE, 'loading');
commit(types.SET_RETRIEVING_BATCHES, true);
eventHub.$emit(EVT_PERF_MARK_DIFF_FILES_START);
@@ -115,7 +112,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
totalLoaded += diff_files.length;
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
- commit(types.SET_BATCH_LOADING, false);
+ commit(types.SET_BATCH_LOADING_STATE, 'loaded');
if (window.gon?.features?.diffsVirtualScrolling && !scrolledVirtualScroller) {
const index = state.diffFiles.findIndex(
@@ -130,7 +127,7 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
}
if (!isNoteLink && !state.currentDiffFileId) {
- commit(types.VIEW_DIFF_FILE, diff_files[0].file_hash);
+ commit(types.VIEW_DIFF_FILE, diff_files[0]?.file_hash);
}
if (isNoteLink) {
@@ -182,11 +179,14 @@ export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => {
return null;
})
- .catch(() => commit(types.SET_RETRIEVING_BATCHES, false));
+ .catch(() => {
+ commit(types.SET_RETRIEVING_BATCHES, false);
+ commit(types.SET_BATCH_LOADING_STATE, 'error');
+ });
- return getBatch()
- .then(() => !window.gon?.features?.diffsVirtualScrolling && handleLocationHash())
- .catch(() => null);
+ return getBatch().then(
+ () => !window.gon?.features?.diffsVirtualScrolling && handleLocationHash(),
+ );
};
export const fetchDiffFilesMeta = ({ commit, state }) => {
@@ -816,9 +816,7 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
};
export const setFileByFile = ({ state, commit }, { fileByFile }) => {
- const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES;
commit(types.SET_FILE_BY_FILE, fileByFile);
- Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode);
if (window.gon?.features?.diffSettingsUsageData) {
const events = [TRACKING_CLICK_SINGLE_FILE_SETTING];
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index 18bd8e5f1d8..ca85be5d829 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -191,3 +191,6 @@ export const isVirtualScrollingEnabled = (state) => {
getParameterValues('virtual_scrolling')[0] === 'true')
);
};
+
+export const isBatchLoading = (state) => state.batchLoadingState === 'loading';
+export const isBatchLoadingError = (state) => state.batchLoadingState === 'error';
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index d76361513d4..a5b1a577a78 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -2,8 +2,6 @@ import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
-import { fileByFile } from '../../utils/preferences';
-
const getViewTypeFromQueryString = () => getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
@@ -12,7 +10,7 @@ const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default () => ({
isLoading: true,
isTreeLoaded: false,
- isBatchLoading: false,
+ batchLoadingState: null,
retrievingBatches: false,
addedLines: null,
removedLines: null,
@@ -36,7 +34,7 @@ export default () => ({
highlightedRow: null,
renderTreeList: true,
showWhitespace: true,
- viewDiffsFileByFile: fileByFile(),
+ viewDiffsFileByFile: false,
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 2c370221f40..60836f747f5 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -1,6 +1,6 @@
export const SET_BASE_CONFIG = 'SET_BASE_CONFIG';
export const SET_LOADING = 'SET_LOADING';
-export const SET_BATCH_LOADING = 'SET_BATCH_LOADING';
+export const SET_BATCH_LOADING_STATE = 'SET_BATCH_LOADING_STATE';
export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES';
export const SET_DIFF_METADATA = 'SET_DIFF_METADATA';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 1aa83453bf7..6bc927b9d1f 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -60,8 +60,8 @@ export default {
Object.assign(state, { isLoading });
},
- [types.SET_BATCH_LOADING](state, isBatchLoading) {
- Object.assign(state, { isBatchLoading });
+ [types.SET_BATCH_LOADING_STATE](state, batchLoadingState) {
+ Object.assign(state, { batchLoadingState });
},
[types.SET_RETRIEVING_BATCHES](state, retrievingBatches) {
diff --git a/app/assets/javascripts/diffs/utils/preferences.js b/app/assets/javascripts/diffs/utils/preferences.js
deleted file mode 100644
index 6b4aaf45937..00000000000
--- a/app/assets/javascripts/diffs/utils/preferences.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import Cookies from 'js-cookie';
-import { DIFF_FILE_BY_FILE_COOKIE_NAME, DIFF_VIEW_FILE_BY_FILE } from '../constants';
-
-export function fileByFile(pref = false) {
- const cookie = Cookies.get(DIFF_FILE_BY_FILE_COOKIE_NAME);
-
- // use the cookie first, if it exists
- if (cookie) {
- return cookie === DIFF_VIEW_FILE_BY_FILE;
- }
-
- return pref;
-}
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 337f7ae2757..f98f63529fc 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -4,6 +4,7 @@ import { escape } from 'lodash';
import './behaviors/preview_markdown';
import { spriteIcon } from '~/lib/utils/common_utils';
import { getFilename } from '~/lib/utils/file_upload';
+import { truncate } from '~/lib/utils/text_utility';
import { n__, __ } from '~/locale';
import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table';
import axios from './lib/utils/axios_utils';
@@ -189,10 +190,13 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
if (image) {
event.preventDefault();
+ const MAX_FILE_NAME_LENGTH = 246;
const filename = getFilename(pasteEvent) || 'image.png';
- const text = `{{${filename}}}`;
+ const truncateFilename = truncate(filename, MAX_FILE_NAME_LENGTH);
+ const text = `{{${truncateFilename}}}`;
pasteText(text);
- return uploadFile(image.getAsFile(), filename);
+
+ return uploadFile(image.getAsFile(), truncateFilename);
}
}
}
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
deleted file mode 100644
index aa223270f2c..00000000000
--- a/app/assets/javascripts/due_date_select.js
+++ /dev/null
@@ -1,191 +0,0 @@
-/* eslint-disable max-classes-per-file */
-import dateFormat from 'dateformat';
-import $ from 'jquery';
-import Pikaday from 'pikaday';
-import initDatePicker from '~/behaviors/date_picker';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { __ } from '~/locale';
-import boardsStore from './boards/stores/boards_store';
-import axios from './lib/utils/axios_utils';
-import { timeFor, parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
-
-class DueDateSelect {
- constructor({ $dropdown, $loading } = {}) {
- const $dropdownParent = $dropdown.closest('.dropdown');
- const $block = $dropdown.closest('.block');
- this.$loading = $loading;
- this.$dropdown = $dropdown;
- this.$dropdownParent = $dropdownParent;
- this.$datePicker = $dropdownParent.find('.js-due-date-calendar');
- this.$block = $block;
- this.$sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
- this.$selectbox = $dropdown.closest('.selectbox');
- this.$value = $block.find('.value');
- this.$valueContent = $block.find('.value-content');
- this.$sidebarValue = $('.js-due-date-sidebar-value', $block);
- this.fieldName = $dropdown.data('fieldName');
- this.abilityName = $dropdown.data('abilityName');
- this.issueUpdateURL = $dropdown.data('issueUpdate');
-
- this.rawSelectedDate = null;
- this.displayedDate = null;
- this.datePayload = null;
-
- this.initGlDropdown();
- this.initRemoveDueDate();
- this.initDatePicker();
- }
-
- initGlDropdown() {
- initDeprecatedJQueryDropdown(this.$dropdown, {
- opened: () => {
- const calendar = this.$datePicker.data('pikaday');
- calendar.show();
- },
- hidden: () => {
- this.$selectbox.hide();
- this.$value.css('display', '');
- },
- shouldPropagate: false,
- });
- }
-
- initDatePicker() {
- const $dueDateInput = $(`input[name='${this.fieldName}']`);
- const calendar = new Pikaday({
- field: $dueDateInput.get(0),
- theme: 'gitlab-theme',
- format: 'yyyy-mm-dd',
- parse: (dateString) => parsePikadayDate(dateString),
- toString: (date) => pikadayToString(date),
- onSelect: (dateText) => {
- $dueDateInput.val(calendar.toString(dateText));
-
- if (this.$dropdown.hasClass('js-issue-boards-due-date')) {
- boardsStore.detail.issue.dueDate = $dueDateInput.val();
- this.updateIssueBoardIssue();
- } else {
- this.saveDueDate(true);
- }
- },
- firstDay: gon.first_day_of_week,
- });
-
- calendar.setDate(parsePikadayDate($dueDateInput.val()));
- this.$datePicker.append(calendar.el);
- this.$datePicker.data('pikaday', calendar);
- }
-
- initRemoveDueDate() {
- this.$block.on('click', '.js-remove-due-date', (e) => {
- const calendar = this.$datePicker.data('pikaday');
- e.preventDefault();
-
- calendar.setDate(null);
-
- if (this.$dropdown.hasClass('js-issue-boards-due-date')) {
- boardsStore.detail.issue.dueDate = '';
- this.updateIssueBoardIssue();
- } else {
- $(`input[name='${this.fieldName}']`).val('');
- this.saveDueDate(false);
- }
- });
- }
-
- saveDueDate(isDropdown) {
- this.parseSelectedDate();
- this.prepSelectedDate();
- this.submitSelectedDate(isDropdown);
- }
-
- parseSelectedDate() {
- this.rawSelectedDate = $(`input[name='${this.fieldName}']`).val();
-
- if (this.rawSelectedDate.length) {
- // Construct Date object manually to avoid buggy dateString support within Date constructor
- const dateArray = this.rawSelectedDate.split('-').map((v) => parseInt(v, 10));
- const dateObj = new Date(dateArray[0], dateArray[1] - 1, dateArray[2]);
- this.displayedDate = dateFormat(dateObj, 'mmm d, yyyy');
- } else {
- this.displayedDate = __('None');
- }
- }
-
- prepSelectedDate() {
- const datePayload = {};
- datePayload[this.abilityName] = {};
- datePayload[this.abilityName].due_date = this.rawSelectedDate;
- this.datePayload = datePayload;
- }
-
- updateIssueBoardIssue() {
- this.$loading.removeClass('gl-display-none');
- this.$dropdown.trigger('loading.gl.dropdown');
- this.$selectbox.hide();
- this.$value.css('display', '');
- const hideLoader = () => {
- this.$loading.addClass('gl-display-none');
- };
-
- boardsStore.detail.issue
- .update(this.$dropdown.attr('data-issue-update'))
- .then(hideLoader)
- .catch(hideLoader);
- }
-
- submitSelectedDate(isDropdown) {
- const selectedDateValue = this.datePayload[this.abilityName].due_date;
- const hasDueDate = this.displayedDate !== __('None');
- const displayedDateStyle = hasDueDate ? 'bold' : 'no-value';
-
- this.$loading.removeClass('gl-display-none');
-
- if (isDropdown) {
- this.$dropdown.trigger('loading.gl.dropdown');
- this.$selectbox.hide();
- }
-
- this.$value.css('display', '');
- this.$valueContent.html(`<span class='${displayedDateStyle}'>${this.displayedDate}</span>`);
- this.$sidebarValue.html(this.displayedDate);
-
- $('.js-remove-due-date-holder').toggleClass('hidden', selectedDateValue.length);
-
- return axios.put(this.issueUpdateURL, this.datePayload).then(() => {
- const tooltipText = hasDueDate
- ? `${__('Due date')}<br />${selectedDateValue} (${timeFor(selectedDateValue)})`
- : __('Due date');
- if (isDropdown) {
- this.$dropdown.trigger('loaded.gl.dropdown');
- this.$dropdown.dropdown('toggle');
- }
- this.$sidebarCollapsedValue.attr('data-original-title', tooltipText);
-
- return this.$loading.addClass('gl-display-none');
- });
- }
-}
-
-export default class DueDateSelectors {
- constructor() {
- initDatePicker();
- this.initIssuableSelect();
- }
- // eslint-disable-next-line class-methods-use-this
- initIssuableSelect() {
- const $loading = $('.js-issuable-update .due_date')
- .find('.block-loading')
- .removeClass('hidden')
- .addClass('gl-display-none');
-
- $('.js-due-date-select').each((i, dropdown) => {
- const $dropdown = $(dropdown);
- // eslint-disable-next-line no-new
- new DueDateSelect({
- $dropdown,
- $loading,
- });
- });
- }
-}
diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js
index 7faf0fe5f08..7672151af2a 100644
--- a/app/assets/javascripts/emoji/index.js
+++ b/app/assets/javascripts/emoji/index.js
@@ -1,5 +1,6 @@
import { escape, minBy } from 'lodash';
import emojiAliases from 'emojis/aliases.json';
+import { sanitize } from '~/lib/dompurify';
import AccessorUtilities from '../lib/utils/accessor';
import axios from '../lib/utils/axios_utils';
import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants';
@@ -10,7 +11,7 @@ export const FALLBACK_EMOJI_KEY = 'grey_question';
export const EMOJI_VERSION = '1';
-const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
async function loadEmoji() {
if (
@@ -34,7 +35,7 @@ async function loadEmoji() {
async function loadEmojiWithNames() {
return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => {
- acc[key] = { ...value, name: key };
+ acc[key] = { ...value, name: key, e: sanitize(value.e) };
return acc;
}, {});
diff --git a/app/assets/javascripts/emoji/support/unicode_support_map.js b/app/assets/javascripts/emoji/support/unicode_support_map.js
index fe3bc75f9fd..d90a774c293 100644
--- a/app/assets/javascripts/emoji/support/unicode_support_map.js
+++ b/app/assets/javascripts/emoji/support/unicode_support_map.js
@@ -141,7 +141,7 @@ function generateUnicodeSupportMap(testMap) {
}
export default function getUnicodeSupportMap() {
- const isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
let glEmojiVersionFromCache;
let userAgentFromCache;
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index 9e058af56c4..cec53869aa8 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -22,10 +22,6 @@ export default {
type: Object,
required: true,
},
- canReadEnvironment: {
- type: Boolean,
- required: true,
- },
},
methods: {
onChangePage(page) {
@@ -42,7 +38,7 @@ export default {
<slot name="empty-state"></slot>
<div v-if="!isLoading && environments.length > 0" class="table-holder">
- <environment-table :environments="environments" :can-read-environment="canReadEnvironment" />
+ <environment-table :environments="environments" />
<table-pagination
v-if="pagination && pagination.totalPages > 1"
diff --git a/app/assets/javascripts/environments/components/edit_environment.vue b/app/assets/javascripts/environments/components/edit_environment.vue
index 1cd960d7cd6..96742a11ebb 100644
--- a/app/assets/javascripts/environments/components/edit_environment.vue
+++ b/app/assets/javascripts/environments/components/edit_environment.vue
@@ -18,6 +18,7 @@ export default {
data() {
return {
formEnvironment: {
+ id: this.environment.id,
name: this.environment.name,
externalUrl: this.environment.external_url,
},
@@ -33,7 +34,6 @@ export default {
axios
.put(this.updateEnvironmentPath, {
id: this.environment.id,
- name: this.formEnvironment.name,
external_url: this.formEnvironment.externalUrl,
})
.then(({ data: { path } }) => visitUrl(path))
diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue
index 6db8fe24e72..1d1d8d61b66 100644
--- a/app/assets/javascripts/environments/components/environment_form.vue
+++ b/app/assets/javascripts/environments/components/environment_form.vue
@@ -39,12 +39,17 @@ export default {
),
nameLabel: __('Name'),
nameFeedback: __('This field is required'),
+ nameDisabledHelp: __("You cannot rename an environment after it's created."),
+ nameDisabledLinkText: __('How do I rename an environment?'),
urlLabel: __('External URL'),
urlFeedback: __('The URL should start with http:// or https://'),
save: __('Save'),
cancel: __('Cancel'),
},
helpPagePath: helpPagePath('ci/environments/index.md'),
+ renamingDisabledHelpPagePath: helpPagePath('ci/environments/index.md', {
+ anchor: 'rename-an-environment',
+ }),
data() {
return {
visited: {
@@ -54,6 +59,9 @@ export default {
};
},
computed: {
+ isNameDisabled() {
+ return Boolean(this.environment.id);
+ },
valid() {
return {
name: this.visited.name && this.environment.name !== '',
@@ -102,10 +110,17 @@ export default {
:state="valid.name"
:invalid-feedback="$options.i18n.nameFeedback"
>
+ <template v-if="isNameDisabled" #description>
+ {{ $options.i18n.nameDisabledHelp }}
+ <gl-link :href="$options.renamingDisabledHelpPagePath" target="_blank">
+ {{ $options.i18n.nameDisabledLinkText }}
+ </gl-link>
+ </template>
<gl-form-input
id="environment_name"
:value="environment.name"
:state="valid.name"
+ :disabled="isNameDisabled"
name="environment[name]"
required
@input="onChange({ ...environment, name: $event })"
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 897f6ce393e..d12863ee742 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable @gitlab/vue-require-i18n-strings */
-import { GlTooltipDirective, GlIcon, GlLink } from '@gitlab/ui';
+import { GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { __, s__, sprintf } from '~/locale';
@@ -32,6 +31,7 @@ export default {
ExternalUrlComponent,
GlIcon,
GlLink,
+ GlSprintf,
MonitoringButtonComponent,
PinComponent,
DeleteComponent,
@@ -48,12 +48,6 @@ export default {
mixins: [timeagoMixin],
props: {
- canReadEnvironment: {
- type: Boolean,
- required: false,
- default: false,
- },
-
model: {
type: Object,
required: true,
@@ -647,14 +641,17 @@ export default {
</span>
<span v-if="!isFolder && deploymentHasUser" class="text-break-word">
- by
- <user-avatar-link
- :link-href="deploymentUser.web_url"
- :img-src="deploymentUser.avatar_url"
- :img-alt="userImageAltDescription"
- :tooltip-text="deploymentUser.username"
- class="js-deploy-user-container float-none"
- />
+ <gl-sprintf :message="s__('Environments|by %{avatar}')">
+ <template #avatar>
+ <user-avatar-link
+ :link-href="deploymentUser.web_url"
+ :img-src="deploymentUser.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="deploymentUser.username"
+ class="js-deploy-user-container float-none"
+ />
+ </template>
+ </gl-sprintf>
</span>
<div v-if="showNoDeployments" class="commit-title table-mobile-content">
@@ -743,13 +740,16 @@ export default {
</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"
- />
+ <gl-sprintf :message="s__('Environments|by %{avatar}')">
+ <template #avatar>
+ <user-avatar-link
+ :link-href="upcomingDeployment.user.web_url"
+ :img-src="upcomingDeployment.user.avatar_url"
+ :img-alt="upcomingDeploymentUserImageAltDescription"
+ :tooltip-text="upcomingDeployment.user.username"
+ />
+ </template>
+ </gl-sprintf>
</span>
</div>
</div>
@@ -784,14 +784,14 @@ export default {
/>
<external-url-component
- v-if="externalURL && canReadEnvironment"
+ v-if="externalURL"
:external-url="externalURL"
data-track-action="click_button"
data-track-label="environment_url"
/>
<monitoring-button-component
- v-if="monitoringUrl && canReadEnvironment"
+ v-if="monitoringUrl"
:monitoring-url="monitoringUrl"
data-track-action="click_button"
data-track-label="environment_monitoring"
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 105315dcf51..acc16ecd874 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,9 +1,7 @@
<script>
-import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs, GlAlert } from '@gitlab/ui';
+import { GlBadge, GlButton, GlModalDirective, GlTab, GlTabs } from '@gitlab/ui';
import createFlash from '~/flash';
-import { setCookie, getCookie, parseBoolean } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
-import { ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME } from '../constants';
import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
import EnvironmentsPaginationApiMixin from '../mixins/environments_pagination_api_mixin';
@@ -17,12 +15,6 @@ export default {
i18n: {
newEnvironmentButtonLabel: s__('Environments|New environment'),
reviewAppButtonLabel: s__('Environments|Enable review app'),
- surveyAlertTitle: s__('Environments|Help us improve environments'),
- surveyAlertText: s__(
- 'Environments|Your feedback helps GitLab make environments better for you and other users. Participate and enter a sweepstake to win a USD 30 gift card.',
- ),
- surveyAlertButtonLabel: s__('Environments|Take the survey'),
- surveyDismissButtonLabel: s__('Environments|Dismiss'),
},
modal: {
id: 'enable-review-app-info',
@@ -33,7 +25,6 @@ export default {
EnableReviewAppModal,
GlBadge,
GlButton,
- GlAlert,
GlTab,
GlTabs,
StopEnvironmentModal,
@@ -52,10 +43,6 @@ export default {
type: Boolean,
required: true,
},
- canReadEnvironment: {
- type: Boolean,
- required: true,
- },
newEnvironmentPath: {
type: String,
required: true,
@@ -65,13 +52,6 @@ export default {
required: true,
},
},
- data() {
- return {
- environmentsSurveyAlertDismissed: parseBoolean(
- getCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME),
- ),
- };
- },
created() {
eventHub.$on('toggleFolder', this.toggleFolder);
@@ -121,11 +101,6 @@ export default {
openFolders.forEach((folder) => this.fetchChildEnvironments(folder));
}
},
-
- onSurveyAlertDismiss() {
- setCookie(ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME, 'true');
- this.environmentsSurveyAlertDismissed = true;
- },
},
};
</script>
@@ -156,19 +131,6 @@ export default {
>{{ $options.i18n.newEnvironmentButtonLabel }}</gl-button
>
</div>
- <gl-alert
- v-if="!environmentsSurveyAlertDismissed"
- class="gl-my-4"
- :title="$options.i18n.surveyAlertTitle"
- :primary-button-text="$options.i18n.surveyAlertButtonLabel"
- variant="info"
- dismissible
- :dismiss-label="$options.i18n.surveyDismissButtonLabel"
- primary-button-link="https://gitlab.fra1.qualtrics.com/jfe/form/SV_a2xyFsAA4D0w0Jg"
- @dismiss="onSurveyAlertDismiss"
- >
- {{ $options.i18n.surveyAlertText }}
- </gl-alert>
<gl-tabs :value="activeTab" content-class="gl-display-none">
<gl-tab
v-for="(tab, idx) in tabs"
@@ -210,7 +172,6 @@ export default {
:is-loading="isLoading"
:environments="state.environments"
:pagination="state.paginationInformation"
- :can-read-environment="canReadEnvironment"
@onChangePage="onChangePage"
>
<template v-if="!isLoading && state.environments.length === 0" #empty-state>
diff --git a/app/assets/javascripts/environments/components/environments_detail_header.vue b/app/assets/javascripts/environments/components/environments_detail_header.vue
index 467c89fd8b8..d71b553a878 100644
--- a/app/assets/javascripts/environments/components/environments_detail_header.vue
+++ b/app/assets/javascripts/environments/components/environments_detail_header.vue
@@ -27,10 +27,6 @@ export default {
type: Object,
required: true,
},
- canReadEnvironment: {
- type: Boolean,
- required: true,
- },
canAdminEnvironment: {
type: Boolean,
required: true,
@@ -84,7 +80,7 @@ export default {
return this.environment.isAvailable && Boolean(this.environment.autoStopAt);
},
shouldShowExternalUrlButton() {
- return this.canReadEnvironment && Boolean(this.environment.externalUrl);
+ return Boolean(this.environment.externalUrl);
},
shouldShowStopButton() {
return this.canStopEnvironment && this.environment.isAvailable;
@@ -138,7 +134,7 @@ export default {
>{{ $options.i18n.externalButtonText }}</gl-button
>
<gl-button
- v-if="canReadEnvironment"
+ v-if="shouldShowExternalUrlButton"
data-testid="metrics-button"
:href="metricsPath"
:title="$options.i18n.metricsButtonTitle"
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 61438872afc..f1c728b84fd 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -23,11 +23,6 @@ export default {
required: true,
default: () => [],
},
- canReadEnvironment: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
@@ -155,7 +150,6 @@ export default {
<environment-item
:key="`environment-item-${i}`"
:model="model"
- :can-read-environment="canReadEnvironment"
:table-data="tableData"
data-qa-selector="environment_item"
/>
@@ -191,7 +185,6 @@ export default {
<environment-item
:key="`environment-row-${i}-${index}`"
:model="child"
- :can-read-environment="canReadEnvironment"
:table-data="tableData"
data-qa-selector="environment_item"
/>
diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js
index a02e72dfa72..6d427bef4e6 100644
--- a/app/assets/javascripts/environments/constants.js
+++ b/app/assets/javascripts/environments/constants.js
@@ -38,5 +38,3 @@ export const CANARY_STATUS = {
};
export const CANARY_UPDATE_MODAL = 'confirm-canary-change';
-
-export const ENVIRONMENTS_SURVEY_DISMISSED_COOKIE_NAME = 'environments_survey_alert_dismissed';
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 1be9a4608cb..206381e0b7e 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import { parseBoolean } from '../../lib/utils/common_utils';
import Translate from '../../vue_shared/translate';
import environmentsFolderApp from './environments_folder_view.vue';
@@ -31,7 +30,6 @@ export default () => {
endpoint: environmentsData.environmentsDataEndpoint,
folderName: environmentsData.environmentsDataFolderName,
cssContainerClass: environmentsData.cssClass,
- canReadEnvironment: parseBoolean(environmentsData.environmentsDataCanReadEnvironment),
};
},
render(createElement) {
@@ -40,7 +38,6 @@ export default () => {
endpoint: this.endpoint,
folderName: this.folderName,
cssContainerClass: this.cssContainerClass,
- canReadEnvironment: this.canReadEnvironment,
},
});
},
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 8070f3f12f8..3c608ad0ba9 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -30,10 +30,6 @@ export default {
required: false,
default: '',
},
- canReadEnvironment: {
- type: Boolean,
- required: true,
- },
},
methods: {
successCallback(resp) {
@@ -72,7 +68,6 @@ export default {
:is-loading="isLoading"
:environments="state.environments"
:pagination="state.paginationInformation"
- :can-read-environment="canReadEnvironment"
@onChangePage="onChangePage"
/>
</div>
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index b99872f7a6c..5e33923d518 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -9,7 +9,7 @@ Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
export default () => {
@@ -32,7 +32,6 @@ export default () => {
newEnvironmentPath: environmentsData.newEnvironmentPath,
helpPagePath: environmentsData.helpPagePath,
canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
- canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment),
};
},
render(createElement) {
@@ -42,7 +41,6 @@ export default () => {
newEnvironmentPath: this.newEnvironmentPath,
helpPagePath: this.helpPagePath,
canCreateEnvironment: this.canCreateEnvironment,
- canReadEnvironment: this.canReadEnvironment,
},
});
},
diff --git a/app/assets/javascripts/environments/mount_show.js b/app/assets/javascripts/environments/mount_show.js
index f1c2dfec94b..6df4fad83f2 100644
--- a/app/assets/javascripts/environments/mount_show.js
+++ b/app/assets/javascripts/environments/mount_show.js
@@ -36,7 +36,6 @@ export const initHeader = () => {
environment: this.environment,
canDestroyEnvironment: dataset.canDestroyEnvironment,
canUpdateEnvironment: dataset.canUpdateEnvironment,
- canReadEnvironment: dataset.canReadEnvironment,
canStopEnvironment: dataset.canStopEnvironment,
canAdminEnvironment: dataset.canAdminEnvironment,
cancelAutoStopPath: dataset.environmentCancelAutoStopPath,
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 2e27f51b71f..5db8c8cf8d3 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -118,7 +118,7 @@ export default {
required: true,
},
},
- hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(),
+ hasLocalStorage: AccessorUtils.canUseLocalStorage(),
data() {
return {
errorSearchQuery: '',
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index 68b4438831e..2b8a31da50f 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlTooltip, GlSprintf, GlIcon } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -132,7 +131,7 @@ export default {
<td
class="line_content"
:class="{ old: isHighlighted(lineNum(line)) }"
- v-html="lineCode(line)"
+ v-html="lineCode(line) /* eslint-disable-line vue/no-v-html */"
></td>
</tr>
</template>
diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js
index d92a64947ad..523861363d7 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutations.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutations.js
@@ -22,7 +22,7 @@ export default {
// only keep the last 5
state.recentSearches = recentSearches.slice(0, 5);
- if (AccessorUtils.isLocalStorageAccessSafe()) {
+ if (AccessorUtils.canUseLocalStorage()) {
localStorage.setItem(
`recent-searches${state.indexPath}`,
JSON.stringify(state.recentSearches),
@@ -31,7 +31,7 @@ export default {
},
[types.CLEAR_RECENT_SEARCHES](state) {
state.recentSearches = [];
- if (AccessorUtils.isLocalStorageAccessSafe()) {
+ if (AccessorUtils.canUseLocalStorage()) {
localStorage.removeItem(`recent-searches${state.indexPath}`);
}
},
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index d188574e721..e12d9cc2b07 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlFormGroup, GlFormCheckbox } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormCheckbox, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
import { mapActions, mapGetters, mapState } from 'vuex';
import ErrorTrackingForm from './error_tracking_form.vue';
import ProjectDropdown from './project_dropdown.vue';
@@ -10,6 +10,8 @@ export default {
GlButton,
GlFormCheckbox,
GlFormGroup,
+ GlFormRadioGroup,
+ GlFormRadio,
ProjectDropdown,
},
props: {
@@ -22,6 +24,10 @@ export default {
type: String,
required: true,
},
+ initialIntegrated: {
+ type: String,
+ required: true,
+ },
initialProject: {
type: String,
required: false,
@@ -49,12 +55,20 @@ export default {
'isProjectInvalid',
'projectSelectionLabel',
]),
- ...mapState(['enabled', 'projects', 'selectedProject', 'settingsLoading', 'token']),
+ ...mapState([
+ 'enabled',
+ 'integrated',
+ 'projects',
+ 'selectedProject',
+ 'settingsLoading',
+ 'token',
+ ]),
},
created() {
this.setInitialState({
apiHost: this.initialApiHost,
enabled: this.initialEnabled,
+ integrated: this.initialIntegrated,
project: this.initialProject,
token: this.initialToken,
listProjectsEndpoint: this.listProjectsEndpoint,
@@ -62,7 +76,13 @@ export default {
});
},
methods: {
- ...mapActions(['setInitialState', 'updateEnabled', 'updateSelectedProject', 'updateSettings']),
+ ...mapActions([
+ 'setInitialState',
+ 'updateEnabled',
+ 'updateIntegrated',
+ 'updateSelectedProject',
+ 'updateSettings',
+ ]),
handleSubmit() {
this.updateSettings();
},
@@ -76,27 +96,44 @@ export default {
:label="s__('ErrorTracking|Enable error tracking')"
label-for="error-tracking-enabled"
>
- <gl-form-checkbox
- id="error-tracking-enabled"
- :checked="enabled"
- @change="updateEnabled($event)"
- >
+ <gl-form-checkbox id="error-tracking-enabled" :checked="enabled" @change="updateEnabled">
{{ s__('ErrorTracking|Active') }}
</gl-form-checkbox>
</gl-form-group>
- <error-tracking-form />
- <div class="form-group">
- <project-dropdown
- :has-projects="hasProjects"
- :invalid-project-label="invalidProjectLabel"
- :is-project-invalid="isProjectInvalid"
- :dropdown-label="dropdownLabel"
- :project-selection-label="projectSelectionLabel"
- :projects="projects"
- :selected-project="selectedProject"
- :token="token"
- @select-project="updateSelectedProject"
- />
+ <gl-form-group
+ :label="s__('ErrorTracking|Error tracking backend')"
+ data-testid="tracking-backend-settings"
+ >
+ <gl-form-radio-group name="explicit" :checked="integrated" @change="updateIntegrated">
+ <gl-form-radio name="error-tracking-integrated" :value="false">
+ {{ __('Sentry') }}
+ <template #help>
+ {{ __('Requires you to deploy or set up cloud-hosted Sentry.') }}
+ </template>
+ </gl-form-radio>
+ <gl-form-radio name="error-tracking-integrated" :value="true">
+ {{ __('GitLab') }}
+ <template #help>
+ {{ __('Uses GitLab as a lightweight alternative to Sentry.') }}
+ </template>
+ </gl-form-radio>
+ </gl-form-radio-group>
+ </gl-form-group>
+ <div v-if="!integrated" class="js-sentry-setting-form" data-testid="sentry-setting-form">
+ <error-tracking-form />
+ <div class="form-group">
+ <project-dropdown
+ :has-projects="hasProjects"
+ :invalid-project-label="invalidProjectLabel"
+ :is-project-invalid="isProjectInvalid"
+ :dropdown-label="dropdownLabel"
+ :project-selection-label="projectSelectionLabel"
+ :projects="projects"
+ :selected-project="selectedProject"
+ :token="token"
+ @select-project="updateSelectedProject"
+ />
+ </div>
</div>
<gl-button
:disabled="settingsLoading"
diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js
index ce315963723..324b3292834 100644
--- a/app/assets/javascripts/error_tracking_settings/index.js
+++ b/app/assets/javascripts/error_tracking_settings/index.js
@@ -5,7 +5,15 @@ import createStore from './store';
export default () => {
const formContainerEl = document.querySelector('.js-error-tracking-form');
const {
- dataset: { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
+ dataset: {
+ apiHost,
+ enabled,
+ integrated,
+ project,
+ token,
+ listProjectsEndpoint,
+ operationsSettingsEndpoint,
+ },
} = formContainerEl;
return new Vue({
@@ -16,6 +24,7 @@ export default () => {
props: {
initialApiHost: apiHost,
initialEnabled: enabled,
+ initialIntegrated: integrated,
initialProject: project,
initialToken: token,
listProjectsEndpoint,
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
index d402d0336d9..972ad58c617 100644
--- a/app/assets/javascripts/error_tracking_settings/store/actions.js
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -79,6 +79,10 @@ export const updateEnabled = ({ commit }, enabled) => {
commit(types.UPDATE_ENABLED, enabled);
};
+export const updateIntegrated = ({ commit }, integrated) => {
+ commit(types.UPDATE_INTEGRATED, integrated);
+};
+
export const updateToken = ({ commit }, token) => {
commit(types.UPDATE_TOKEN, token);
commit(types.RESET_CONNECT);
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
index bf3df383ddc..2cfa14c9b64 100644
--- a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
+++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
@@ -6,6 +6,7 @@ export const UPDATE_API_HOST = 'UPDATE_API_HOST';
export const UPDATE_CONNECT_ERROR = 'UPDATE_CONNECT_ERROR';
export const UPDATE_CONNECT_SUCCESS = 'UPDATE_CONNECT_SUCCESS';
export const UPDATE_ENABLED = 'UPDATE_ENABLED';
+export const UPDATE_INTEGRATED = 'UPDATE_INTEGRATED';
export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT';
export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING';
export const UPDATE_TOKEN = 'UPDATE_TOKEN';
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js
index 2242169aa1e..a1b43ccaaee 100644
--- a/app/assets/javascripts/error_tracking_settings/store/mutations.js
+++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js
@@ -20,9 +20,18 @@ export default {
},
[types.SET_INITIAL_STATE](
state,
- { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
+ {
+ apiHost,
+ enabled,
+ integrated,
+ project,
+ token,
+ listProjectsEndpoint,
+ operationsSettingsEndpoint,
+ },
) {
state.enabled = parseBoolean(enabled);
+ state.integrated = parseBoolean(integrated);
state.apiHost = apiHost;
state.token = token;
state.listProjectsEndpoint = listProjectsEndpoint;
@@ -38,6 +47,9 @@ export default {
[types.UPDATE_ENABLED](state, enabled) {
state.enabled = enabled;
},
+ [types.UPDATE_INTEGRATED](state, integrated) {
+ state.integrated = integrated;
+ },
[types.UPDATE_TOKEN](state, token) {
state.token = token;
},
diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js
index ab616f11e83..ee5597abeb3 100644
--- a/app/assets/javascripts/error_tracking_settings/store/state.js
+++ b/app/assets/javascripts/error_tracking_settings/store/state.js
@@ -1,6 +1,7 @@
export default () => ({
apiHost: '',
enabled: false,
+ integrated: false,
token: '',
projects: [],
isLoadingProjects: false,
diff --git a/app/assets/javascripts/error_tracking_settings/utils.js b/app/assets/javascripts/error_tracking_settings/utils.js
index 5d18ac8e802..7ef5f7bbd34 100644
--- a/app/assets/javascripts/error_tracking_settings/utils.js
+++ b/app/assets/javascripts/error_tracking_settings/utils.js
@@ -1,6 +1,12 @@
export const projectKeys = ['name', 'organizationName', 'organizationSlug', 'slug'];
-export const transformFrontendSettings = ({ apiHost, enabled, token, selectedProject }) => {
+export const transformFrontendSettings = ({
+ apiHost,
+ enabled,
+ integrated,
+ token,
+ selectedProject,
+}) => {
const project = selectedProject
? {
slug: selectedProject.slug,
@@ -10,7 +16,7 @@ export const transformFrontendSettings = ({ apiHost, enabled, token, selectedPro
}
: null;
- return { api_host: apiHost || null, enabled, token: token || null, project };
+ return { api_host: apiHost || null, enabled, integrated, token: token || null, project };
};
export const getDisplayName = (project) => `${project.organizationName} | ${project.slug}`;
diff --git a/app/assets/javascripts/experimentation/utils.js b/app/assets/javascripts/experimentation/utils.js
index e572280a62c..9079c238169 100644
--- a/app/assets/javascripts/experimentation/utils.js
+++ b/app/assets/javascripts/experimentation/utils.js
@@ -1,18 +1,27 @@
// This file only applies to use of experiments through https://gitlab.com/gitlab-org/gitlab-experiment
-import { get } from 'lodash';
+import { get, pick } from 'lodash';
import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants';
+function getExperimentsData() {
+ return get(window, ['gon', 'experiment'], {});
+}
+
+function convertExperimentDataToExperimentContext(experimentData) {
+ return { schema: TRACKING_CONTEXT_SCHEMA, data: experimentData };
+}
+
export function getExperimentData(experimentName) {
- return get(window, ['gon', 'experiment', experimentName]);
+ return getExperimentsData()[experimentName];
}
export function getExperimentContexts(...experimentNames) {
- return experimentNames
- .map((name) => {
- const data = getExperimentData(name);
- return data && { schema: TRACKING_CONTEXT_SCHEMA, data };
- })
- .filter((context) => context);
+ return Object.values(pick(getExperimentsData(), experimentNames)).map(
+ convertExperimentDataToExperimentContext,
+ );
+}
+
+export function getAllExperimentContexts() {
+ return Object.values(getExperimentsData()).map(convertExperimentDataToExperimentContext);
}
export function isExperimentVariant(experimentName, variantName) {
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 dde021b67be..05d557db942 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -48,7 +48,7 @@ export default {
<gl-toggle
:value="active"
data-testid="feature-flag-status-toggle"
- data-track-event="click_button"
+ data-track-action="click_button"
data-track-label="feature_flag_toggle"
class="gl-mr-4"
:label="__('Feature flag status')"
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 cfd838bf5a1..f8a8bed2467 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -115,7 +115,7 @@ export default {
:label="$options.i18n.toggleLabel"
label-position="hidden"
data-testid="feature-flag-status-toggle"
- data-track-event="click_button"
+ data-track-action="click_button"
data-track-label="feature_flag_toggle"
@change="toggleFeatureFlag(featureFlag)"
/>
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
index 56824977a43..c3514198ad9 100644
--- a/app/assets/javascripts/filtered_search/services/recent_searches_service.js
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
@@ -33,7 +33,7 @@ class RecentSearchesService {
}
static isAvailable() {
- return AccessorUtilities.isLocalStorageAccessSafe();
+ return AccessorUtilities.canUseLocalStorage();
}
}
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index dd405893e43..8ad9eeaa266 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -84,7 +84,7 @@ export default {
logItemAccess(storageKey, unsanitizedItem) {
const item = sanitizeItem(unsanitizedItem);
- if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (!AccessorUtilities.canUseLocalStorage()) {
return false;
}
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 1137951ccfc..2f451e8353b 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,5 +1,5 @@
<script>
-/* eslint-disable vue/require-default-prop, vue/no-v-html */
+/* eslint-disable vue/require-default-prop */
import { GlButton } from '@gitlab/ui';
import highlight from '~/lib/utils/highlight';
import { truncateNamespace } from '~/lib/utils/text_utility';
@@ -75,7 +75,7 @@ export default {
ref="frequentItemsItemTitle"
:title="itemName"
class="frequent-items-item-title"
- v-html="highlightedItemName"
+ v-html="highlightedItemName /* eslint-disable-line vue/no-v-html */"
></div>
<div
v-if="namespace"
diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js
index 65a762f54ad..babc2ef2e32 100644
--- a/app/assets/javascripts/frequent_items/store/actions.js
+++ b/app/assets/javascripts/frequent_items/store/actions.js
@@ -25,7 +25,7 @@ export const receiveFrequentItemsError = ({ commit }) => {
export const fetchFrequentItems = ({ state, dispatch }) => {
dispatch('requestFrequentItems');
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (AccessorUtilities.canUseLocalStorage()) {
const storedFrequentItems = JSON.parse(localStorage.getItem(state.storageKey));
dispatch(
diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js
index 312dd0c88dd..692de9dcb88 100644
--- a/app/assets/javascripts/graphql_shared/constants.js
+++ b/app/assets/javascripts/graphql_shared/constants.js
@@ -1,3 +1,5 @@
+export const MINIMUM_SEARCH_LENGTH = 3;
+
export const TYPE_CI_RUNNER = 'Ci::Runner';
export const TYPE_EPIC = 'Epic';
export const TYPE_GROUP = 'Group';
@@ -11,3 +13,5 @@ export const TYPE_SCANNER_PROFILE = 'DastScannerProfile';
export const TYPE_SITE_PROFILE = 'DastSiteProfile';
export const TYPE_USER = 'User';
export const TYPE_VULNERABILITY = 'Vulnerability';
+export const TYPE_NOTE = 'Note';
+export const TYPE_DISCUSSION = 'Discussion';
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 2a95b242510..a1ec5942d64 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -136,7 +136,7 @@ export default {
this.updateGroups(res, Boolean(filterGroupsBy));
});
},
- fetchPage(page, filterGroupsBy, sortBy, archived) {
+ fetchPage({ page, filterGroupsBy, sortBy, archived }) {
this.isLoading = true;
return this.fetchGroups({
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index 59a37b2a1d5..18a6d487703 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -32,10 +32,10 @@ export default {
},
methods: {
change(page) {
- const filterGroupsParam = getParameterByName('filter');
- const sortParam = getParameterByName('sort');
- const archivedParam = getParameterByName('archived');
- eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam);
+ const filterGroupsBy = getParameterByName('filter');
+ const sortBy = getParameterByName('sort');
+ const archived = getParameterByName('archived');
+ eventHub.$emit(`${this.action}fetchPage`, { page, filterGroupsBy, sortBy, archived });
},
},
};
diff --git a/app/assets/javascripts/groups/components/invite_members_banner.vue b/app/assets/javascripts/groups/components/invite_members_banner.vue
index 402d9a07c53..dfc1549fb4a 100644
--- a/app/assets/javascripts/groups/components/invite_members_banner.vue
+++ b/app/assets/javascripts/groups/components/invite_members_banner.vue
@@ -1,7 +1,7 @@
<script>
import { GlBanner } from '@gitlab/ui';
import eventHub from '~/invite_members/event_hub';
-import { parseBoolean, setCookie, getCookie } from '~/lib/utils/common_utils';
+import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
@@ -12,10 +12,10 @@ export default {
GlBanner,
},
mixins: [trackingMixin],
- inject: ['svgPath', 'isDismissedKey', 'trackLabel'],
+ inject: ['svgPath', 'trackLabel', 'calloutsPath', 'calloutsFeatureId', 'groupId'],
data() {
return {
- isDismissed: parseBoolean(getCookie(this.isDismissedKey)),
+ isDismissed: false,
tracking: {
label: this.trackLabel,
},
@@ -26,7 +26,16 @@ export default {
},
methods: {
handleClose() {
- setCookie(this.isDismissedKey, true);
+ axios
+ .post(this.calloutsPath, {
+ feature_name: this.calloutsFeatureId,
+ group_id: this.groupId,
+ })
+ .catch((e) => {
+ // eslint-disable-next-line @gitlab/require-i18n-strings, no-console
+ console.error('Failed to dismiss banner.', e);
+ });
+
this.isDismissed = true;
this.track(this.$options.dismissEvent);
},
@@ -61,6 +70,7 @@ export default {
<gl-banner
v-if="!isDismissed"
ref="banner"
+ data-testid="invite-members-banner"
:title="$options.i18n.title"
:button-text="$options.i18n.button_text"
:svg-path="svgPath"
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 7a37d1eb93d..46e9d2bec99 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -40,24 +40,31 @@ export default {
return this.item.type === ITEM_TYPE.GROUP;
},
},
+ methods: {
+ displayValue(value) {
+ return this.isGroup && value !== undefined;
+ },
+ },
};
</script>
<template>
<div class="stats gl-text-gray-500">
<item-stats-value
- v-if="isGroup"
+ v-if="displayValue(item.subgroupCount)"
:title="__('Subgroups')"
:value="item.subgroupCount"
css-class="number-subgroups gl-ml-5"
icon-name="folder-o"
+ data-testid="subgroups-count"
/>
<item-stats-value
- v-if="isGroup"
+ v-if="displayValue(item.projectCount)"
:title="__('Projects')"
:value="item.projectCount"
css-class="number-projects gl-ml-5"
icon-name="bookmark"
+ data-testid="projects-count"
/>
<item-stats-value
v-if="isGroup"
diff --git a/app/assets/javascripts/groups/init_invite_members_banner.js b/app/assets/javascripts/groups/init_invite_members_banner.js
index 2052dd6ac8c..38ab4122dab 100644
--- a/app/assets/javascripts/groups/init_invite_members_banner.js
+++ b/app/assets/javascripts/groups/init_invite_members_banner.js
@@ -8,15 +8,24 @@ export default function initInviteMembersBanner() {
return false;
}
- const { svgPath, inviteMembersPath, isDismissedKey, trackLabel } = el.dataset;
+ const {
+ svgPath,
+ inviteMembersPath,
+ trackLabel,
+ calloutsPath,
+ calloutsFeatureId,
+ groupId,
+ } = el.dataset;
return new Vue({
el,
provide: {
svgPath,
inviteMembersPath,
- isDismissedKey,
trackLabel,
+ calloutsPath,
+ calloutsFeatureId,
+ groupId,
},
render: (createElement) => createElement(InviteMembersBanner),
});
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
new file mode 100644
index 00000000000..580c27f6c61
--- /dev/null
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -0,0 +1,83 @@
+<script>
+import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import HeaderSearchDefaultItems from './header_search_default_items.vue';
+import HeaderSearchScopedItems from './header_search_scoped_items.vue';
+
+export default {
+ name: 'HeaderSearchApp',
+ i18n: {
+ searchPlaceholder: __('Search or jump to...'),
+ },
+ directives: { Outside },
+ components: {
+ GlSearchBoxByType,
+ HeaderSearchDefaultItems,
+ HeaderSearchScopedItems,
+ },
+ data() {
+ return {
+ showDropdown: false,
+ };
+ },
+ computed: {
+ ...mapState(['search']),
+ ...mapGetters(['searchQuery']),
+ searchText: {
+ get() {
+ return this.search;
+ },
+ set(value) {
+ this.setSearch(value);
+ },
+ },
+ showSearchDropdown() {
+ return this.showDropdown && gon?.current_username;
+ },
+ showDefaultItems() {
+ return !this.searchText;
+ },
+ },
+ methods: {
+ ...mapActions(['setSearch']),
+ openDropdown() {
+ this.showDropdown = true;
+ },
+ closeDropdown() {
+ this.showDropdown = false;
+ },
+ submitSearch() {
+ return visitUrl(this.searchQuery);
+ },
+ },
+};
+</script>
+
+<template>
+ <section v-outside="closeDropdown" class="header-search gl-relative">
+ <gl-search-box-by-type
+ v-model="searchText"
+ :debounce="500"
+ autocomplete="off"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @focus="openDropdown"
+ @click="openDropdown"
+ @keydown.enter="submitSearch"
+ @keydown.esc="closeDropdown"
+ />
+ <div
+ v-if="showSearchDropdown"
+ data-testid="header-search-dropdown-menu"
+ class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-left-0 gl-z-index-1 gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
+ >
+ <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
+ <header-search-default-items v-if="showDefaultItems" />
+ <template v-else>
+ <header-search-scoped-items />
+ </template>
+ </div>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue
new file mode 100644
index 00000000000..2871937ed3a
--- /dev/null
+++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import { __ } from '~/locale';
+
+export default {
+ name: 'HeaderSearchDefaultItems',
+ i18n: {
+ allGitLab: __('All GitLab'),
+ },
+ components: {
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ },
+ computed: {
+ ...mapState(['searchContext']),
+ ...mapGetters(['defaultSearchOptions']),
+ sectionHeader() {
+ return (
+ this.searchContext.project?.name ||
+ this.searchContext.group?.name ||
+ this.$options.i18n.allGitLab
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(option, index) in defaultSearchOptions"
+ :id="`default-${index}`"
+ :key="index"
+ tabindex="-1"
+ :href="option.url"
+ >
+ {{ option.title }}
+ </gl-dropdown-item>
+ </div>
+</template>
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
new file mode 100644
index 00000000000..645eba05148
--- /dev/null
+++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
@@ -0,0 +1,31 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+
+export default {
+ name: 'HeaderSearchScopedItems',
+ components: {
+ GlDropdownItem,
+ },
+ computed: {
+ ...mapState(['search']),
+ ...mapGetters(['scopedSearchOptions']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown-item
+ v-for="(option, index) in scopedSearchOptions"
+ :id="`scoped-${index}`"
+ :key="index"
+ tabindex="-1"
+ :href="option.url"
+ >
+ "<span class="gl-font-weight-bold">{{ search }}</span
+ >" {{ option.description }}
+ <span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span>
+ </gl-dropdown-item>
+ </div>
+</template>
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
new file mode 100644
index 00000000000..fffed7bcbdb
--- /dev/null
+++ b/app/assets/javascripts/header_search/constants.js
@@ -0,0 +1,17 @@
+import { __ } from '~/locale';
+
+export const MSG_ISSUES_ASSIGNED_TO_ME = __('Issues assigned to me');
+
+export const MSG_ISSUES_IVE_CREATED = __("Issues I've created");
+
+export const MSG_MR_ASSIGNED_TO_ME = __('Merge requests assigned to me');
+
+export const MSG_MR_IM_REVIEWER = __("Merge requests that I'm a reviewer");
+
+export const MSG_MR_IVE_CREATED = __("Merge requests I've created");
+
+export const MSG_IN_ALL_GITLAB = __('in all GitLab');
+
+export const MSG_IN_GROUP = __('in group');
+
+export const MSG_IN_PROJECT = __('in project');
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
new file mode 100644
index 00000000000..2d37ee137fc
--- /dev/null
+++ b/app/assets/javascripts/header_search/index.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import HeaderSearchApp from './components/app.vue';
+import createStore from './store';
+
+Vue.use(Translate);
+
+export const initHeaderSearchApp = () => {
+ const el = document.getElementById('js-header-search');
+
+ if (!el) {
+ return false;
+ }
+
+ const { searchPath, issuesPath, mrPath } = el.dataset;
+ let { searchContext } = el.dataset;
+ searchContext = JSON.parse(searchContext);
+
+ return new Vue({
+ el,
+ store: createStore({ searchPath, issuesPath, mrPath, searchContext }),
+ render(createElement) {
+ return createElement(HeaderSearchApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/header_search/store/actions.js b/app/assets/javascripts/header_search/store/actions.js
new file mode 100644
index 00000000000..841aee04029
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/actions.js
@@ -0,0 +1,5 @@
+import * as types from './mutation_types';
+
+export const setSearch = ({ commit }, value) => {
+ commit(types.SET_SEARCH, value);
+};
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
new file mode 100644
index 00000000000..d1e1fc8ad73
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -0,0 +1,135 @@
+import { objectToQuery } from '~/lib/utils/url_utility';
+
+import {
+ MSG_ISSUES_ASSIGNED_TO_ME,
+ MSG_ISSUES_IVE_CREATED,
+ MSG_MR_ASSIGNED_TO_ME,
+ MSG_MR_IM_REVIEWER,
+ MSG_MR_IVE_CREATED,
+ MSG_IN_PROJECT,
+ MSG_IN_GROUP,
+ MSG_IN_ALL_GITLAB,
+} from '../constants';
+
+export const searchQuery = (state) => {
+ const query = {
+ search: state.search,
+ nav_source: 'navbar',
+ project_id: state.searchContext.project?.id,
+ group_id: state.searchContext.group?.id,
+ scope: state.searchContext.scope,
+ };
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const scopedIssuesPath = (state) => {
+ return (
+ state.searchContext.project_metadata?.issues_path ||
+ state.searchContext.group_metadata?.issues_path ||
+ state.issuesPath
+ );
+};
+
+export const scopedMRPath = (state) => {
+ return (
+ state.searchContext.project_metadata?.mr_path ||
+ state.searchContext.group_metadata?.mr_path ||
+ state.mrPath
+ );
+};
+
+export const defaultSearchOptions = (state, getters) => {
+ const userName = gon.current_username;
+
+ return [
+ {
+ title: MSG_ISSUES_ASSIGNED_TO_ME,
+ url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
+ },
+ {
+ title: MSG_ISSUES_IVE_CREATED,
+ url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
+ },
+ {
+ title: MSG_MR_ASSIGNED_TO_ME,
+ url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
+ },
+ {
+ title: MSG_MR_IM_REVIEWER,
+ url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
+ },
+ {
+ title: MSG_MR_IVE_CREATED,
+ url: `${getters.scopedMRPath}/?author_username=${userName}`,
+ },
+ ];
+};
+
+export const projectUrl = (state) => {
+ if (!state.searchContext.project || !state.searchContext.group) {
+ return null;
+ }
+
+ const query = {
+ search: state.search,
+ nav_source: 'navbar',
+ project_id: state.searchContext.project.id,
+ group_id: state.searchContext.group.id,
+ scope: state.searchContext.scope,
+ };
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const groupUrl = (state) => {
+ if (!state.searchContext.group) {
+ return null;
+ }
+
+ const query = {
+ search: state.search,
+ nav_source: 'navbar',
+ group_id: state.searchContext.group.id,
+ scope: state.searchContext.scope,
+ };
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const allUrl = (state) => {
+ const query = {
+ search: state.search,
+ nav_source: 'navbar',
+ scope: state.searchContext.scope,
+ };
+
+ return `${state.searchPath}?${objectToQuery(query)}`;
+};
+
+export const scopedSearchOptions = (state, getters) => {
+ const options = [];
+
+ if (state.searchContext.project) {
+ options.push({
+ scope: state.searchContext.project.name,
+ description: MSG_IN_PROJECT,
+ url: getters.projectUrl,
+ });
+ }
+
+ if (state.searchContext.group) {
+ options.push({
+ scope: state.searchContext.group.name,
+ description: MSG_IN_GROUP,
+ url: getters.groupUrl,
+ });
+ }
+
+ options.push({
+ description: MSG_IN_ALL_GITLAB,
+ url: getters.allUrl,
+ });
+
+ return options;
+};
diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js
new file mode 100644
index 00000000000..8b74f8662a5
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const getStoreConfig = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
+ actions,
+ getters,
+ mutations,
+ state: createState({ searchPath, issuesPath, mrPath, searchContext }),
+});
+
+const createStore = (config) => new Vuex.Store(getStoreConfig(config));
+export default createStore;
diff --git a/app/assets/javascripts/header_search/store/mutation_types.js b/app/assets/javascripts/header_search/store/mutation_types.js
new file mode 100644
index 00000000000..0bc94ae055f
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/mutation_types.js
@@ -0,0 +1 @@
+export const SET_SEARCH = 'SET_SEARCH';
diff --git a/app/assets/javascripts/header_search/store/mutations.js b/app/assets/javascripts/header_search/store/mutations.js
new file mode 100644
index 00000000000..5b1438929d4
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/mutations.js
@@ -0,0 +1,7 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_SEARCH](state, value) {
+ state.search = value;
+ },
+};
diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js
new file mode 100644
index 00000000000..fb2c83dbbe3
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/state.js
@@ -0,0 +1,8 @@
+const createState = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
+ searchPath,
+ issuesPath,
+ mrPath,
+ searchContext,
+ search: '',
+});
+export default createState;
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
index 977efb0ca22..5a7d7917f8a 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { mapState } from 'vuex';
export default {
@@ -17,7 +16,7 @@ export default {
<div class="gl-mr-3 gl-ml-3">
<div class="text-content text-center">
<h4>{{ __('All changes are committed') }}</h4>
- <p v-html="lastCommitMsg"></p>
+ <p v-html="lastCommitMsg /* eslint-disable-line vue/no-v-html */"></p>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/ide/components/error_message.vue b/app/assets/javascripts/ide/components/error_message.vue
index 2b75d10f659..67eedc6b37f 100644
--- a/app/assets/javascripts/ide/components/error_message.vue
+++ b/app/assets/javascripts/ide/components/error_message.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlSafeHtmlDirective } from '@gitlab/ui';
import { mapActions } from 'vuex';
export default {
@@ -8,6 +7,9 @@ export default {
GlAlert,
GlLoadingIcon,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
message: {
type: Object,
@@ -56,7 +58,7 @@ export default {
@dismiss="dismiss"
@primaryAction="doAction"
>
- <span v-html="message.text"></span>
+ <span v-safe-html="message.text"></span>
<gl-loading-icon v-show="isLoading" size="sm" inline class="vertical-align-middle ml-1" />
</gl-alert>
</template>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index 8e611503cb4..c142992a9d1 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui';
import { throttle } from 'lodash';
import { mapActions, mapState } from 'vuex';
@@ -102,7 +101,7 @@ export default {
<code
v-show="!detailJob.isLoading"
class="bash"
- v-html="jobOutput"
+ v-html="jobOutput /* eslint-disable-line vue/no-v-html */"
>
</code>
<div
diff --git a/app/assets/javascripts/ide/services/terminals.js b/app/assets/javascripts/ide/services/terminals.js
index ea54733baa4..99121948196 100644
--- a/app/assets/javascripts/ide/services/terminals.js
+++ b/app/assets/javascripts/ide/services/terminals.js
@@ -1,6 +1,8 @@
import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
-export const baseUrl = (projectPath) => `/${projectPath}/ide_terminals`;
+export const baseUrl = (projectPath) =>
+ joinPaths(gon.relative_url_root || '', `/${projectPath}/ide_terminals`);
export const checkConfig = (projectPath, branch) =>
axios.post(`${baseUrl(projectPath)}/check_config`, {
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 275fecc5a32..ec3630cc5eb 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -43,7 +43,10 @@ const KNOWN_TYPES = [
},
];
-export function isTextFile({ name, raw, content, mimeType = '' }) {
+export function isTextFile({ name, raw, binary, content, mimeType = '' }) {
+ // some file objects already have a `binary` property set on them. If so, use it first
+ if (typeof binary === 'boolean') return !binary;
+
const knownType = KNOWN_TYPES.find((type) => type.isMatch(mimeType, name));
if (knownType) return knownType.isText;
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
new file mode 100644
index 00000000000..104c84173fc
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue
@@ -0,0 +1,69 @@
+<script>
+import { GlButton, GlIcon, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { isFinished, isInvalid, isAvailableForImport } from '../utils';
+
+export default {
+ components: {
+ GlIcon,
+ GlButton,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ groupPathRegex: {
+ type: RegExp,
+ required: true,
+ },
+ },
+ computed: {
+ fullLastImportPath() {
+ return this.group.last_import_target
+ ? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
+ : null;
+ },
+ absoluteLastImportPath() {
+ return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
+ },
+ isAvailableForImport() {
+ return isAvailableForImport(this.group);
+ },
+ isFinished() {
+ return isFinished(this.group);
+ },
+ isInvalid() {
+ return isInvalid(this.group, this.groupPathRegex);
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="gl-white-space-nowrap gl-inline-flex gl-align-items-center">
+ <gl-button
+ v-if="isAvailableForImport"
+ :disabled="isInvalid"
+ variant="confirm"
+ category="secondary"
+ data-qa-selector="import_group_button"
+ @click="$emit('import-group')"
+ >
+ {{ isFinished ? __('Re-import') : __('Import') }}
+ </gl-button>
+ <gl-icon
+ v-if="isFinished"
+ v-gl-tooltip
+ :size="16"
+ name="information-o"
+ :title="
+ s__('BulkImports|Re-import creates a new group. It does not sync with the existing group.')
+ "
+ class="gl-ml-3"
+ />
+ </span>
+</template>
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue
new file mode 100644
index 00000000000..2de9bd4f868
--- /dev/null
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_source_cell.vue
@@ -0,0 +1,53 @@
+<script>
+import { GlLink, GlSprintf, GlIcon } from '@gitlab/ui';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { isFinished } from '../utils';
+
+export default {
+ components: {
+ GlLink,
+ GlSprintf,
+ GlIcon,
+ },
+ props: {
+ group: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ fullLastImportPath() {
+ return this.group.last_import_target
+ ? `${this.group.last_import_target.target_namespace}/${this.group.last_import_target.new_name}`
+ : null;
+ },
+ absoluteLastImportPath() {
+ return joinPaths(gon.relative_url_root || '/', this.fullLastImportPath);
+ },
+ isFinished() {
+ return isFinished(this.group);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-link
+ :href="group.web_url"
+ target="_blank"
+ class="gl-display-inline-flex gl-align-items-center gl-h-7"
+ >
+ {{ group.full_path }} <gl-icon name="external-link" />
+ </gl-link>
+ <div v-if="isFinished && fullLastImportPath" class="gl-font-sm">
+ <gl-sprintf :message="s__('BulkImport|Last imported to %{link}')">
+ <template #link>
+ <gl-link :href="absoluteLastImportPath" class="gl-font-sm" target="_blank">{{
+ fullLastImportPath
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+ </div>
+</template>
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
index db44be2bcd7..04b037ecc2b 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue
@@ -9,19 +9,19 @@ import {
GlLoadingIcon,
GlSearchBoxByClick,
GlSprintf,
- GlSafeHtmlDirective as SafeHtml,
GlTable,
GlFormCheckbox,
} from '@gitlab/ui';
import { s__, __, n__ } from '~/locale';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
-import ImportStatus from '../../components/import_status.vue';
-import { STATUSES } from '../../constants';
+import ImportStatusCell from '../../components/import_status.vue';
import importGroupsMutation from '../graphql/mutations/import_groups.mutation.graphql';
import setImportTargetMutation from '../graphql/mutations/set_import_target.mutation.graphql';
import availableNamespacesQuery from '../graphql/queries/available_namespaces.query.graphql';
import bulkImportSourceGroupsQuery from '../graphql/queries/bulk_import_source_groups.query.graphql';
-import { isInvalid } from '../utils';
+import { isInvalid, isFinished, isAvailableForImport } from '../utils';
+import ImportActionsCell from './import_actions_cell.vue';
+import ImportSourceCell from './import_source_cell.vue';
import ImportTargetCell from './import_target_cell.vue';
const PAGE_SIZES = [20, 50, 100];
@@ -43,13 +43,12 @@ export default {
GlFormCheckbox,
GlSprintf,
GlTable,
- ImportStatus,
+ ImportSourceCell,
ImportTargetCell,
+ ImportStatusCell,
+ ImportActionsCell,
PaginationLinks,
},
- directives: {
- SafeHtml,
- },
props: {
sourceUrl: {
@@ -136,7 +135,7 @@ export default {
},
availableGroupsForImport() {
- return this.groups.filter((g) => g.progress.status === STATUSES.NONE && !this.isInvalid(g));
+ return this.groups.filter((g) => isAvailableForImport(g) && !this.isInvalid(g));
},
humanizedTotal() {
@@ -190,6 +189,24 @@ export default {
},
methods: {
+ isUnselectable(group) {
+ return !this.isAvailableForImport(group) || this.isInvalid(group);
+ },
+
+ rowClasses(group) {
+ const DEFAULT_CLASSES = [
+ 'gl-border-gray-200',
+ 'gl-border-0',
+ 'gl-border-b-1',
+ 'gl-border-solid',
+ ];
+ const result = [...DEFAULT_CLASSES];
+ if (this.isUnselectable(group)) {
+ result.push('gl-cursor-default!');
+ }
+ return result;
+ },
+
qaRowAttributes(group, type) {
if (type === 'row') {
return {
@@ -201,10 +218,8 @@ export default {
return {};
},
- isAlreadyImported(group) {
- return group.progress.status !== STATUSES.NONE;
- },
-
+ isAvailableForImport,
+ isFinished,
isInvalid(group) {
return isInvalid(group, this.groupPathRegex);
},
@@ -253,7 +268,7 @@ export default {
const table = this.getTableRef();
this.groups.forEach((group, idx) => {
- if (table.isRowSelected(idx) && (this.isAlreadyImported(group) || this.isInvalid(group))) {
+ if (table.isRowSelected(idx) && this.isUnselectable(group)) {
table.unselectRow(idx);
}
});
@@ -291,7 +306,7 @@ export default {
<strong>{{ filter }}</strong>
</template>
<template #link>
- <gl-link class="gl-display-inline-block" :href="sourceUrl" target="_blank">
+ <gl-link :href="sourceUrl" target="_blank">
{{ sourceUrl }} <gl-icon name="external-link" class="vertical-align-middle" />
</gl-link>
</template>
@@ -338,7 +353,7 @@ export default {
ref="table"
class="gl-w-full"
data-qa-selector="import_table"
- tbody-tr-class="gl-border-gray-200 gl-border-0 gl-border-b-1 gl-border-solid"
+ :tbody-tr-class="rowClasses"
:tbody-tr-attr="qaRowAttributes"
:items="groups"
:fields="$options.fields"
@@ -360,18 +375,12 @@ export default {
<gl-form-checkbox
class="gl-h-7 gl-pt-3"
:checked="rowSelected"
- :disabled="isAlreadyImported(group) || isInvalid(group)"
+ :disabled="!isAvailableForImport(group) || isInvalid(group)"
@change="rowSelected ? unselectRow() : selectRow()"
/>
</template>
- <template #cell(web_url)="{ value: web_url, item: { full_path } }">
- <gl-link
- :href="web_url"
- target="_blank"
- class="gl-display-inline-flex gl-align-items-center gl-h-7"
- >
- {{ full_path }} <gl-icon name="external-link" />
- </gl-link>
+ <template #cell(web_url)="{ item: group }">
+ <import-source-cell :group="group" />
</template>
<template #cell(import_target)="{ item: group }">
<import-target-cell
@@ -388,19 +397,14 @@ export default {
/>
</template>
<template #cell(progress)="{ value: { status } }">
- <import-status :status="status" class="gl-line-height-32" />
+ <import-status-cell :status="status" class="gl-line-height-32" />
</template>
<template #cell(actions)="{ item: group }">
- <gl-button
- v-if="!isAlreadyImported(group)"
- :disabled="isInvalid(group)"
- variant="confirm"
- category="secondary"
- data-qa-selector="import_group_button"
- @click="importGroups([group.id])"
- >
- {{ __('Import') }}
- </gl-button>
+ <import-actions-cell
+ :group="group"
+ :group-path-regex="groupPathRegex"
+ @import-group="importGroups([group.id])"
+ />
</template>
</gl-table>
<div v-if="hasGroups" class="gl-display-flex gl-mt-3 gl-align-items-center">
diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
index 7359d4f239e..daced740c94 100644
--- a/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
+++ b/app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue
@@ -3,14 +3,16 @@ import {
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
- GlLink,
GlFormInput,
} from '@gitlab/ui';
-import { joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import ImportGroupDropdown from '../../components/group_dropdown.vue';
-import { STATUSES } from '../../constants';
-import { isInvalid, getInvalidNameValidationMessage, isNameValid } from '../utils';
+import {
+ isInvalid,
+ getInvalidNameValidationMessage,
+ isNameValid,
+ isAvailableForImport,
+} from '../utils';
export default {
components: {
@@ -18,7 +20,6 @@ export default {
GlDropdownDivider,
GlDropdownItem,
GlDropdownSectionHeader,
- GlLink,
GlFormInput,
},
props: {
@@ -61,20 +62,8 @@ export default {
return isNameValid(this.group, this.groupPathRegex);
},
- isAlreadyImported() {
- return this.group.progress.status !== STATUSES.NONE;
- },
-
- isFinished() {
- return this.group.progress.status === STATUSES.FINISHED;
- },
-
- fullPath() {
- return `${this.importTarget.target_namespace}/${this.importTarget.new_name}`;
- },
-
- absolutePath() {
- return joinPaths(gon.relative_url_root || '/', this.fullPath);
+ isAvailableForImport() {
+ return isAvailableForImport(this.group);
},
},
@@ -85,25 +74,11 @@ export default {
</script>
<template>
- <gl-link
- v-if="isFinished"
- class="gl-display-inline-flex gl-align-items-center gl-h-7"
- :href="absolutePath"
- >
- {{ fullPath }}
- </gl-link>
-
- <div
- v-else
- class="gl-display-flex gl-align-items-stretch"
- :class="{
- disabled: isAlreadyImported,
- }"
- >
+ <div class="gl-display-flex gl-align-items-stretch">
<import-group-dropdown
#default="{ namespaces }"
:text="importTarget.target_namespace"
- :disabled="isAlreadyImported"
+ :disabled="!isAvailableForImport"
:namespaces="availableNamespaceNames"
toggle-class="gl-rounded-top-right-none! gl-rounded-bottom-right-none!"
class="gl-h-7 gl-flex-grow-1"
@@ -131,8 +106,8 @@ export default {
<div
class="gl-h-7 gl-px-3 gl-display-flex gl-align-items-center gl-border-solid gl-border-0 gl-border-t-1 gl-border-b-1 gl-bg-gray-10"
:class="{
- 'gl-text-gray-400 gl-border-gray-100': isAlreadyImported,
- 'gl-border-gray-200': !isAlreadyImported,
+ 'gl-text-gray-400 gl-border-gray-100': !isAvailableForImport,
+ 'gl-border-gray-200': isAvailableForImport,
}"
>
/
@@ -141,11 +116,11 @@ export default {
<gl-form-input
class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
:class="{
- 'gl-inset-border-1-gray-200!': !isAlreadyImported,
- 'gl-inset-border-1-gray-100!': isAlreadyImported,
- 'is-invalid': isInvalid && !isAlreadyImported,
+ 'gl-inset-border-1-gray-200!': isAvailableForImport,
+ 'gl-inset-border-1-gray-100!': !isAvailableForImport,
+ 'is-invalid': isInvalid && isAvailableForImport,
}"
- :disabled="isAlreadyImported"
+ :disabled="!isAvailableForImport"
:value="importTarget.new_name"
@input="$emit('update-new-name', $event)"
/>
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
index 57188441158..c08cf909a00 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/client_factory.js
@@ -5,10 +5,13 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { STATUSES } from '../../constants';
import { i18n, NEW_NAME_FIELD } from '../constants';
+import { isAvailableForImport } from '../utils';
import bulkImportSourceGroupItemFragment from './fragments/bulk_import_source_group_item.fragment.graphql';
+import bulkImportSourceGroupProgressFragment from './fragments/bulk_import_source_group_progress.fragment.graphql';
import addValidationErrorMutation from './mutations/add_validation_error.mutation.graphql';
import removeValidationErrorMutation from './mutations/remove_validation_error.mutation.graphql';
import setImportProgressMutation from './mutations/set_import_progress.mutation.graphql';
+import setImportTargetMutation from './mutations/set_import_target.mutation.graphql';
import updateImportStatusMutation from './mutations/update_import_status.mutation.graphql';
import availableNamespacesQuery from './queries/available_namespaces.query.graphql';
import bulkImportSourceGroupQuery from './queries/bulk_import_source_group.query.graphql';
@@ -34,6 +37,7 @@ function makeGroup(data) {
};
const NESTED_OBJECT_FIELDS = {
import_target: clientTypenames.BulkImportTarget,
+ last_import_target: clientTypenames.BulkImportTarget,
progress: clientTypenames.BulkImportProgress,
};
@@ -55,6 +59,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour
data: { existingGroup, existingProject },
} = await client.query({
query: groupAndProjectQuery,
+ fetchPolicy: 'no-cache',
variables: {
fullPath: `${targetNamespace}/${newName}`,
},
@@ -82,6 +87,7 @@ async function checkImportTargetIsValid({ client, newName, targetNamespace, sour
}
const localProgressId = (id) => `not-started-${id}`;
+const nextName = (name) => `${name}-1`;
export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
const groupsManager = new GroupsManager({
@@ -140,17 +146,28 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
const { jobId, importState: cachedImportState } =
groupsManager.getImportStateFromStorageByGroupId(group.id) ?? {};
+ const status = cachedImportState?.status ?? STATUSES.NONE;
+
+ const importTarget =
+ status === STATUSES.FINISHED && cachedImportState.importTarget
+ ? {
+ target_namespace: cachedImportState.importTarget.target_namespace,
+ new_name: nextName(cachedImportState.importTarget.new_name),
+ }
+ : cachedImportState?.importTarget ?? {
+ new_name: group.full_path,
+ target_namespace: availableNamespaces[0]?.full_path ?? '',
+ };
+
return makeGroup({
...group,
validation_errors: [],
progress: {
id: jobId ?? localProgressId(group.id),
- status: cachedImportState?.status ?? STATUSES.NONE,
- },
- import_target: cachedImportState?.importTarget ?? {
- new_name: group.full_path,
- target_namespace: availableNamespaces[0]?.full_path ?? '',
+ status,
},
+ import_target: importTarget,
+ last_import_target: cachedImportState?.importTarget ?? null,
});
}),
pageInfo: {
@@ -161,7 +178,7 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
setTimeout(() => {
response.nodes.forEach((group) => {
- if (group.progress.status === STATUSES.NONE) {
+ if (isAvailableForImport(group)) {
checkImportTargetIsValid({
client,
newName: group.import_target.new_name,
@@ -193,32 +210,18 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
targetNamespace,
newName,
});
+
return makeGroup({
id: sourceGroupId,
import_target: {
target_namespace: targetNamespace,
new_name: newName,
+ id: sourceGroupId,
},
});
},
- setTargetNamespace: (_, { targetNamespace, sourceGroupId }) =>
- makeGroup({
- id: sourceGroupId,
- import_target: {
- target_namespace: targetNamespace,
- },
- }),
-
- setNewName: (_, { newName, sourceGroupId }) =>
- makeGroup({
- id: sourceGroupId,
- import_target: {
- new_name: newName,
- },
- }),
-
- async setImportProgress(_, { sourceGroupId, status, jobId }) {
+ async setImportProgress(_, { sourceGroupId, status, jobId, importTarget }) {
if (jobId) {
groupsManager.updateImportProgress(jobId, status);
}
@@ -229,16 +232,46 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
id: jobId ?? localProgressId(sourceGroupId),
status,
},
+ last_import_target: {
+ __typename: clientTypenames.BulkImportTarget,
+ ...importTarget,
+ },
});
},
- async updateImportStatus(_, { id, status }) {
- groupsManager.updateImportProgress(id, status);
+ async updateImportStatus(_, { id, status: newStatus }, { client, getCacheKey }) {
+ groupsManager.updateImportProgress(id, newStatus);
+
+ const progressItem = client.readFragment({
+ fragment: bulkImportSourceGroupProgressFragment,
+ fragmentName: 'BulkImportSourceGroupProgress',
+ id: getCacheKey({
+ __typename: clientTypenames.BulkImportProgress,
+ id,
+ }),
+ });
+
+ const isInProgress = Boolean(progressItem);
+ const { status: currentStatus } = progressItem ?? {};
+ if (newStatus === STATUSES.FINISHED && isInProgress && currentStatus !== newStatus) {
+ const groups = groupsManager.getImportedGroupsByJobId(id);
+
+ groups.forEach(async ({ id: groupId, importTarget }) => {
+ client.mutate({
+ mutation: setImportTargetMutation,
+ variables: {
+ sourceGroupId: groupId,
+ targetNamespace: importTarget.target_namespace,
+ newName: nextName(importTarget.new_name),
+ },
+ });
+ });
+ }
return {
__typename: clientTypenames.BulkImportProgress,
id,
- status,
+ status: newStatus,
};
},
@@ -327,10 +360,10 @@ export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGr
return { status: STATUSES.NONE };
})
.then((newStatus) =>
- sourceGroupIds.forEach((sourceGroupId) =>
+ sourceGroupIds.forEach((sourceGroupId, idx) =>
client.mutate({
mutation: setImportProgressMutation,
- variables: { sourceGroupId, ...newStatus },
+ variables: { sourceGroupId, ...newStatus, importTarget: groups[idx].import_target },
}),
),
)
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
index 47675cd1bd0..089340b3c48 100644
--- 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
@@ -12,6 +12,10 @@ fragment BulkImportSourceGroupItem on ClientBulkImportSourceGroup {
target_namespace
new_name
}
+ last_import_target {
+ target_namespace
+ new_name
+ }
validation_errors {
field
message
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
index 2ec1269932a..43301554de3 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/mutations/set_import_progress.mutation.graphql
@@ -1,9 +1,23 @@
-mutation setImportProgress($status: String!, $sourceGroupId: String!, $jobId: String) {
- setImportProgress(status: $status, sourceGroupId: $sourceGroupId, jobId: $jobId) @client {
+mutation setImportProgress(
+ $status: String!
+ $sourceGroupId: String!
+ $jobId: String
+ $importTarget: ImportTargetInput!
+) {
+ setImportProgress(
+ status: $status
+ sourceGroupId: $sourceGroupId
+ jobId: $jobId
+ importTarget: $importTarget
+ ) @client {
id
progress {
id
status
}
+ last_import_target {
+ target_namespace
+ new_name
+ }
}
}
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
index 97dbdbf518a..7caa37d9ad4 100644
--- 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
@@ -35,15 +35,18 @@ export class SourceGroupsManager {
}
createImportState(importId, jobConfig) {
- this.importStates[this.getStorageKey(importId)] = {
+ this.importStates[importId] = {
status: jobConfig.status,
- groups: jobConfig.groups.map((g) => ({ importTarget: g.import_target, id: g.id })),
+ groups: jobConfig.groups.map((g) => ({
+ importTarget: { ...g.import_target },
+ id: g.id,
+ })),
};
this.saveImportStatesToStorage();
}
updateImportProgress(importId, status) {
- const currentState = this.importStates[this.getStorageKey(importId)];
+ const currentState = this.importStates[importId];
if (!currentState) {
return;
}
@@ -52,12 +55,15 @@ export class SourceGroupsManager {
this.saveImportStatesToStorage();
}
+ getImportedGroupsByJobId(jobId) {
+ return this.importStates[jobId]?.groups ?? [];
+ }
+
getImportStateFromStorageByGroupId(groupId) {
- const PREFIX = this.getStorageKey('');
const [jobId, importState] =
- Object.entries(this.importStates).find(
- ([key, state]) => key.startsWith(PREFIX) && state.groups.some((g) => g.id === groupId),
- ) ?? [];
+ Object.entries(this.importStates)
+ .reverse()
+ .find(([, state]) => state.groups.some((g) => g.id === groupId)) ?? [];
if (!jobId) {
return null;
@@ -67,10 +73,6 @@ export class SourceGroupsManager {
return { jobId, importState: { ...group, status: importState.status } };
}
- getStorageKey(importId) {
- return `${this.sourceUrl}|${importId}`;
- }
-
saveImportStatesToStorage = debounce(() => {
try {
// storage might be changed in other tab so fetch first
diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
index c830aaa75e6..6ef4bbafec0 100644
--- a/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
+++ b/app/assets/javascripts/import_entities/import_groups/graphql/typedefs.graphql
@@ -30,6 +30,7 @@ type ClientBulkImportSourceGroup {
full_name: String!
progress: ClientBulkImportProgress!
import_target: ClientBulkImportTarget!
+ last_import_target: ClientBulkImportTarget
validation_errors: [ClientBulkImportValidationError!]!
}
@@ -50,11 +51,21 @@ extend type Query {
availableNamespaces: [ClientBulkImportAvailableNamespace!]!
}
+input InputTargetInput {
+ target_namespace: String!
+ new_name: String!
+}
+
extend type Mutation {
setNewName(newName: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
setTargetNamespace(targetNamespace: String, sourceGroupId: ID!): ClientBulkImportSourceGroup!
importGroups(sourceGroupIds: [ID!]!): [ClientBulkImportSourceGroup!]!
- setImportProgress(id: ID, status: String!): ClientBulkImportSourceGroup!
+ setImportProgress(
+ id: ID
+ status: String!
+ jobId: String
+ importTarget: ImportTargetInput!
+ ): ClientBulkImportSourceGroup!
updateImportProgress(id: ID, status: String!): ClientBulkImportProgress
addValidationError(
sourceGroupId: ID!
diff --git a/app/assets/javascripts/import_entities/import_groups/utils.js b/app/assets/javascripts/import_entities/import_groups/utils.js
index b451008b6f9..a1baeaf39dd 100644
--- a/app/assets/javascripts/import_entities/import_groups/utils.js
+++ b/app/assets/javascripts/import_entities/import_groups/utils.js
@@ -1,3 +1,4 @@
+import { STATUSES } from '../constants';
import { NEW_NAME_FIELD } from './constants';
export function isNameValid(group, validationRegex) {
@@ -11,3 +12,11 @@ export function getInvalidNameValidationMessage(group) {
export function isInvalid(group, validationRegex) {
return Boolean(!isNameValid(group, validationRegex) || getInvalidNameValidationMessage(group));
}
+
+export function isFinished(group) {
+ return group.progress.status === STATUSES.FINISHED;
+}
+
+export function isAvailableForImport(group) {
+ return [STATUSES.NONE, STATUSES.FINISHED].some((status) => group.progress.status === status);
+}
diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
index 14d08caef34..0cd3519bcec 100644
--- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
+++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue
@@ -32,7 +32,7 @@ export default {
},
computed: {
- ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace']),
+ ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']),
...mapGetters([
'isLoading',
'isImportingAnyRepo',
@@ -43,7 +43,7 @@ export default {
]),
pagePaginationStateKey() {
- return `${this.filter}-${this.repositories.length}`;
+ return `${this.filter}-${this.repositories.length}-${this.pageInfo.page}`;
},
availableNamespaces() {
diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js
index 5cbc6e85bf3..92be028b8a9 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/actions.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js
@@ -53,7 +53,6 @@ const importAll = ({ state, dispatch }) => {
const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) => {
const nextPage = state.pageInfo.page + 1;
- commit(types.SET_PAGE, nextPage);
commit(types.REQUEST_REPOS);
const { provider, filter } = state;
@@ -67,11 +66,10 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit })
}),
)
.then(({ data }) => {
+ commit(types.SET_PAGE, nextPage);
commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true }));
})
.catch((e) => {
- commit(types.SET_PAGE, nextPage - 1);
-
if (hasRedirectInError(e)) {
redirectToUrlInError(e);
} else if (tooManyRequests(e)) {
diff --git a/app/assets/javascripts/import_entities/import_projects/store/mutations.js b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
index c5e1922597a..45f7a684161 100644
--- a/app/assets/javascripts/import_entities/import_projects/store/mutations.js
+++ b/app/assets/javascripts/import_entities/import_projects/store/mutations.js
@@ -9,7 +9,7 @@ const makeNewImportedProject = (importedProject) => ({
sanitizedName: importedProject.name,
providerLink: importedProject.providerLink,
},
- importedProject,
+ importedProject: { ...importedProject },
});
const makeNewIncompatibleProject = (project) => ({
@@ -63,15 +63,16 @@ export default {
factory: makeNewIncompatibleProject,
});
- state.repositories = [
- ...newImportedProjects,
- ...state.repositories,
- ...repositories.providerRepos.map((project) => ({
+ const existingProjects = [...newImportedProjects, ...state.repositories];
+ const existingProjectNames = new Set(existingProjects.map((p) => p.importSource.fullName));
+ const newProjects = repositories.providerRepos
+ .filter((project) => !existingProjectNames.has(project.fullName))
+ .map((project) => ({
importSource: project,
importedProject: null,
- })),
- ...newIncompatibleProjects,
- ];
+ }));
+
+ state.repositories = [...existingProjects, ...newProjects, ...newIncompatibleProjects];
if (incompatibleRepos.length === 0 && repositories.providerRepos.length === 0) {
state.pageInfo.page -= 1;
diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js
index 8644ff3a249..6e6461cd7a9 100644
--- a/app/assets/javascripts/incidents/list.js
+++ b/app/assets/javascripts/incidents/list.js
@@ -24,7 +24,7 @@ export default () => {
} = domEl.dataset;
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
return new Vue({
diff --git a/app/assets/javascripts/init_changes_dropdown.js b/app/assets/javascripts/init_changes_dropdown.js
deleted file mode 100644
index b42264c870b..00000000000
--- a/app/assets/javascripts/init_changes_dropdown.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import $ from 'jquery';
-import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
-import { stickyMonitor } from './lib/utils/sticky';
-
-export default (stickyTop) => {
- stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop);
-
- initDeprecatedJQueryDropdown($('.js-diff-stats-dropdown'), {
- filterable: true,
- remoteFilter: false,
- });
-};
diff --git a/app/assets/javascripts/init_notes.js b/app/assets/javascripts/init_deprecated_notes.js
index a77828e8cf2..5f918b0d2f5 100644
--- a/app/assets/javascripts/init_notes.js
+++ b/app/assets/javascripts/init_deprecated_notes.js
@@ -1,4 +1,4 @@
-import Notes from './notes';
+import Notes from './deprecated_notes';
export default () => {
const dataEl = document.querySelector('.js-notes-data');
diff --git a/app/assets/javascripts/init_diff_stats_dropdown.js b/app/assets/javascripts/init_diff_stats_dropdown.js
new file mode 100644
index 00000000000..27df761a103
--- /dev/null
+++ b/app/assets/javascripts/init_diff_stats_dropdown.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import DiffStatsDropdown from '~/vue_shared/components/diff_stats_dropdown.vue';
+import { stickyMonitor } from './lib/utils/sticky';
+
+export const initDiffStatsDropdown = (stickyTop) => {
+ if (stickyTop) {
+ stickyMonitor(document.querySelector('.js-diff-files-changed'), stickyTop);
+ }
+
+ const el = document.querySelector('.js-diff-stats-dropdown');
+
+ if (!el) {
+ return false;
+ }
+
+ const { changed, added, deleted, files } = el.dataset;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(DiffStatsDropdown, {
+ props: {
+ changed: parseInt(changed, 10),
+ added: parseInt(added, 10),
+ deleted: parseInt(deleted, 10),
+ files: JSON.parse(files),
+ },
+ }),
+ });
+};
diff --git a/app/assets/javascripts/init_issuable_sidebar.js b/app/assets/javascripts/init_issuable_sidebar.js
index 17c73fdf1c3..7a70d893008 100644
--- a/app/assets/javascripts/init_issuable_sidebar.js
+++ b/app/assets/javascripts/init_issuable_sidebar.js
@@ -1,9 +1,7 @@
/* eslint-disable no-new */
-import { mountSidebarLabels, getSidebarOptions } from '~/sidebar/mount_sidebar';
+import { getSidebarOptions } from '~/sidebar/mount_sidebar';
import IssuableContext from './issuable_context';
-import LabelsSelect from './labels_select';
-import MilestoneSelect from './milestone_select';
import Sidebar from './right_sidebar';
export default () => {
@@ -13,12 +11,6 @@ export default () => {
const sidebarOptions = getSidebarOptions(sidebarOptEl);
- new MilestoneSelect({
- full_path: sidebarOptions.fullPath,
- });
- new LabelsSelect();
new IssuableContext(sidebarOptions.currentUser);
Sidebar.initialize();
-
- mountSidebarLabels();
};
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
index 11e9b25f9a3..1cc5a185f03 100644
--- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -37,7 +37,7 @@ const issueTransitionOptions = [
help: s__(
'JiraService|Automatically transitions Jira issues to the "Done" category. %{linkStart}Learn more%{linkEnd}',
),
- link: helpPagePath('integration/jira/index.html', {
+ link: helpPagePath('integration/jira/issues.html', {
anchor: 'automatic-issue-transitions',
}),
},
@@ -47,7 +47,7 @@ const issueTransitionOptions = [
help: s__(
'JiraService|Set a custom final state by using transition IDs. %{linkStart}Learn about transition IDs%{linkEnd}',
),
- link: helpPagePath('integration/jira/index.html', {
+ link: helpPagePath('integration/jira/issues.html', {
anchor: 'custom-issue-transitions',
}),
},
diff --git a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue b/app/assets/javascripts/invite_members/components/import_a_project_modal.vue
new file mode 100644
index 00000000000..d71468284ca
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/import_a_project_modal.vue
@@ -0,0 +1,157 @@
+<script>
+import { GlButton, GlFormGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { importProjectMembers } from '~/api/projects_api';
+import { s__, __, sprintf } from '~/locale';
+import ProjectSelect from './project_select.vue';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlModal,
+ GlSprintf,
+ ProjectSelect,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ projectName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projectToBeImported: {},
+ invalidFeedbackMessage: '',
+ isLoading: false,
+ };
+ },
+ computed: {
+ modalIntro() {
+ return sprintf(this.$options.i18n.modalIntro, {
+ name: this.projectName,
+ });
+ },
+ importDisabled() {
+ return Object.keys(this.projectToBeImported).length === 0;
+ },
+ validationState() {
+ return this.invalidFeedbackMessage === '' ? null : false;
+ },
+ },
+ methods: {
+ submitImport() {
+ this.isLoading = true;
+ return importProjectMembers(this.projectId, this.projectToBeImported.id)
+ .then(this.showToastMessage)
+ .catch(this.showErrorAlert)
+ .finally(() => {
+ this.isLoading = false;
+ this.projectToBeImported = {};
+ });
+ },
+ closeModal() {
+ this.invalidFeedbackMessage = '';
+
+ this.$refs.modal.hide();
+ },
+ showToastMessage() {
+ this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions);
+
+ this.closeModal();
+ },
+ showErrorAlert() {
+ this.invalidFeedbackMessage = this.$options.i18n.defaultError;
+ },
+ },
+ toastOptions() {
+ return {
+ onComplete: () => {
+ this.projectToBeImported = {};
+ },
+ };
+ },
+ i18n: {
+ buttonText: s__('ImportAProjectModal|Import from a project'),
+ projectLabel: __('Project'),
+ modalTitle: s__('ImportAProjectModal|Import members from another project'),
+ modalIntro: s__(
+ "ImportAProjectModal|You're importing members to the %{strongStart}%{name}%{strongEnd} project.",
+ ),
+ modalHelpText: s__(
+ 'ImportAProjectModal|Only project members (not group members) are imported, and they get the same permissions as the project you import from.',
+ ),
+ modalPrimaryButton: s__('ImportAProjectModal|Import project members'),
+ modalCancelButton: __('Cancel'),
+ defaultError: s__('ImportAProjectModal|Unable to import project members'),
+ successMessage: s__('ImportAProjectModal|Successfully imported'),
+ },
+ projectSelectLabelId: 'project-select',
+ modalId: uniqueId('import-a-project-modal-'),
+ formClasses: 'gl-mt-3 gl-sm-w-auto gl-w-full',
+ buttonClasses: 'gl-w-full',
+};
+</script>
+
+<template>
+ <form :class="$options.formClasses">
+ <gl-button v-gl-modal="$options.modalId" :class="$options.buttonClasses" variant="default">{{
+ $options.i18n.buttonText
+ }}</gl-button>
+
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ size="sm"
+ :title="$options.i18n.modalTitle"
+ ok-variant="danger"
+ footer-class="gl-bg-gray-10 gl-p-5"
+ >
+ <div>
+ <p ref="modalIntro">
+ <gl-sprintf :message="modalIntro">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-group
+ :invalid-feedback="invalidFeedbackMessage"
+ :state="validationState"
+ data-testid="form-group"
+ >
+ <label :id="$options.projectSelectLabelId" class="col-form-label">{{
+ $options.i18n.projectLabel
+ }}</label>
+ <project-select v-model="projectToBeImported" />
+ </gl-form-group>
+ <p>{{ $options.i18n.modalHelpText }}</p>
+ </div>
+ <template #modal-footer>
+ <div
+ class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"
+ >
+ <gl-button data-testid="cancel-button" @click="closeModal">
+ {{ $options.i18n.modalCancelButton }}
+ </gl-button>
+ <div class="gl-mr-3"></div>
+ <gl-button
+ :disabled="importDisabled"
+ :loading="isLoading"
+ variant="success"
+ data-testid="import-button"
+ @click="submitImport"
+ >{{ $options.i18n.modalPrimaryButton }}</gl-button
+ >
+ </div>
+ </template>
+ </gl-modal>
+ </form>
+</template>
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index ec7d466336e..05be427742c 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -65,7 +65,7 @@ export default {
if (this.event && this.label) {
return {
...baseAttributes,
- 'data-track-event': this.event,
+ 'data-track-action': this.event,
'data-track-label': this.label,
};
}
diff --git a/app/assets/javascripts/invite_members/components/project_select.vue b/app/assets/javascripts/invite_members/components/project_select.vue
new file mode 100644
index 00000000000..b7a3918813b
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/project_select.vue
@@ -0,0 +1,143 @@
+<script>
+import {
+ GlAvatarLabeled,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
+import { getProjects } from '~/rest_api';
+import { SEARCH_DELAY, GROUP_FILTERS } from '../constants';
+
+export default {
+ name: 'ProjectSelect',
+ components: {
+ GlAvatarLabeled,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ },
+ model: {
+ prop: 'selectedProject',
+ },
+ props: {
+ groupsFilter: {
+ type: String,
+ required: false,
+ default: GROUP_FILTERS.ALL,
+ validator: (value) => Object.values(GROUP_FILTERS).includes(value),
+ },
+ parentGroupId: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ },
+ data() {
+ return {
+ isFetching: false,
+ projects: [],
+ selectedProject: {},
+ searchTerm: '',
+ errorMessage: '',
+ };
+ },
+ computed: {
+ selectedProjectName() {
+ return this.selectedProject.name || this.$options.i18n.dropdownText;
+ },
+ isFetchResultEmpty() {
+ return this.projects.length === 0 && !this.isFetching;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.retrieveProjects();
+ },
+ },
+ mounted() {
+ this.retrieveProjects();
+ },
+ methods: {
+ retrieveProjects: debounce(function debouncedRetrieveProjects() {
+ this.isFetching = true;
+ this.errorMessage = '';
+ return this.fetchProjects()
+ .then((response) => {
+ this.projects = response.data.map((project) => ({
+ ...convertObjectPropsToCamelCase(project),
+ name: project.name_with_namespace,
+ }));
+ })
+ .catch(() => {
+ this.errorMessage = this.$options.i18n.errorFetchingProjects;
+ })
+ .finally(() => {
+ this.isFetching = false;
+ });
+ }, SEARCH_DELAY),
+ fetchProjects() {
+ return getProjects(this.searchTerm, this.$options.defaultFetchOptions);
+ },
+ selectProject(project) {
+ this.selectedProject = project;
+
+ this.$emit('input', this.selectedProject);
+ },
+ },
+ i18n: {
+ dropdownText: s__('ProjectSelect|Select a project'),
+ searchPlaceholder: s__('ProjectSelect|Search projects'),
+ emptySearchResult: s__('ProjectSelect|No matching results'),
+ errorFetchingProjects: s__(
+ 'ProjectSelect|There was an error fetching the projects. Please try again.',
+ ),
+ },
+ defaultFetchOptions: {
+ exclude_internal: true,
+ active: true,
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-dropdown
+ data-testid="project-select-dropdown"
+ :text="selectedProjectName"
+ toggle-class="gl-mb-2"
+ block
+ menu-class="gl-w-full!"
+ >
+ <gl-search-box-by-type
+ v-model="searchTerm"
+ :is-loading="isFetching"
+ :placeholder="$options.i18n.searchPlaceholder"
+ data-qa-selector="project_select_dropdown_search_field"
+ />
+ <gl-dropdown-item
+ v-for="project in projects"
+ :key="project.id"
+ :name="project.name"
+ @click="selectProject(project)"
+ >
+ <gl-avatar-labeled
+ :label="project.name"
+ :src="project.avatarUrl"
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :size="32"
+ />
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="errorMessage" data-testid="error-message">
+ <span class="gl-text-gray-500">{{ errorMessage }}</span>
+ </gl-dropdown-text>
+ <gl-dropdown-text v-else-if="isFetchResultEmpty" data-testid="empty-result-message">
+ <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
+ </gl-dropdown-text>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/invite_members/init_import_a_project_modal.js b/app/assets/javascripts/invite_members/init_import_a_project_modal.js
new file mode 100644
index 00000000000..954347467de
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_import_a_project_modal.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
+
+export default function initImportAProjectModal() {
+ const el = document.querySelector('.js-import-a-project-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ const { projectId, projectName } = el.dataset;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(ImportAProjectModal, {
+ props: {
+ projectId,
+ projectName,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/issuable/components/csv_export_modal.vue b/app/assets/javascripts/issuable/components/csv_export_modal.vue
index 5c880cbfad8..1c88f8dfdca 100644
--- a/app/assets/javascripts/issuable/components/csv_export_modal.vue
+++ b/app/assets/javascripts/issuable/components/csv_export_modal.vue
@@ -63,7 +63,7 @@ export default {
</gl-sprintf>
<gl-sprintf
v-else
- :message="n__('1 merge request selected', '%d merge request selected', issuableCount)"
+ :message="n__('1 merge request selected', '%d merge requests selected', issuableCount)"
>
<template #issuableCount>{{ issuableCount }}</template>
</gl-sprintf>
@@ -89,7 +89,7 @@ export default {
:href="exportCsvPath"
data-method="post"
:data-qa-selector="`export_${issuableType}_button`"
- data-track-event="click_button"
+ data-track-action="click_button"
:data-track-label="`export_${issuableType}_csv`"
>
<gl-sprintf :message="__('Export %{name}')">
diff --git a/app/assets/javascripts/issuable_list/components/issuable_item.vue b/app/assets/javascripts/issuable_list/components/issuable_item.vue
index 29dd0b7fed5..df9d5c86a4b 100644
--- a/app/assets/javascripts/issuable_list/components/issuable_item.vue
+++ b/app/assets/javascripts/issuable_list/components/issuable_item.vue
@@ -69,6 +69,9 @@ export default {
isIssuableUrlExternal() {
return isExternal(this.webUrl);
},
+ reference() {
+ return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`;
+ },
labels() {
return this.issuable.labels?.nodes || this.issuable.labels || [];
},
@@ -201,9 +204,9 @@ export default {
</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 v-else data-testid="issuable-reference" class="issuable-reference">
+ {{ reference }}
+ </span>
<span class="issuable-authored gl-display-none gl-sm-display-inline-block! gl-mr-3">
<span aria-hidden="true">&middot;</span>
<span
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index b7e24a8b17e..2c9a512acdb 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlIntersectionObserver } from '@gitlab/ui';
+import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
import Poll from '~/lib/utils/poll';
@@ -32,6 +32,9 @@ export default {
formComponent,
PinnedLinks,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
endpoint: {
required: true,
@@ -183,6 +186,11 @@ export default {
required: false,
default: true,
},
+ isHidden: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
const store = new Store({
@@ -508,6 +516,15 @@ export default {
<span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon">
<gl-icon name="eye-slash" :aria-label="__('Confidential')" />
</span>
+ <span
+ v-if="isHidden"
+ v-gl-tooltip
+ :title="__('This issue is hidden because its author has been banned')"
+ data-testid="hidden"
+ class="issuable-warning-icon"
+ >
+ <gl-icon name="spam" />
+ </span>
<p
class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
:title="state.titleText"
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 0812392f804..4c6a1478e95 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -123,6 +123,7 @@ export default {
}
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -136,7 +137,7 @@ export default {
>
<div
ref="gfm-content"
- v-safe-html="descriptionHtml"
+ v-safe-html:[$options.safeHtmlConfig]="descriptionHtml"
:class="{
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
index 96f5a7c88e0..f3c2a31bd5b 100644
--- a/app/assets/javascripts/issue_show/components/locked_warning.vue
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { __, sprintf } from '~/locale';
export default {
@@ -24,5 +23,8 @@ export default {
</script>
<template>
- <div class="alert alert-danger" v-html="alertMessage"></div>
+ <div
+ class="alert alert-danger"
+ v-html="alertMessage /* eslint-disable-line vue/no-v-html */"
+ ></div>
</template>
diff --git a/app/assets/javascripts/issues_list/components/issuable.vue b/app/assets/javascripts/issues_list/components/issuable.vue
index 60b01a6d37f..6dc7460b037 100644
--- a/app/assets/javascripts/issues_list/components/issuable.vue
+++ b/app/assets/javascripts/issues_list/components/issuable.vue
@@ -315,7 +315,7 @@ export default {
<span
v-if="isJiraIssue"
v-safe-html="jiraLogo"
- class="svg-container jira-logo-container"
+ class="svg-container logo-container"
data-testid="jira-logo"
></span>
{{ referencePath }}
diff --git a/app/assets/javascripts/issues_list/components/issues_list_app.vue b/app/assets/javascripts/issues_list/components/issues_list_app.vue
index ee0429c0432..8e37339fca6 100644
--- a/app/assets/javascripts/issues_list/components/issues_list_app.vue
+++ b/app/assets/javascripts/issues_list/components/issues_list_app.vue
@@ -9,11 +9,12 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
-import { cloneDeep } from 'lodash';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
+import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
import createFlash from '~/flash';
import { TYPE_USER } from '~/graphql_shared/constants';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { ITEM_TYPE } from '~/groups/constants';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
@@ -21,7 +22,6 @@ import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import {
CREATED_DESC,
i18n,
- issuesCountSmartQueryBase,
MAX_LIST_SIZE,
PAGE_SIZE,
PARAM_DUE_DATE,
@@ -117,9 +117,15 @@ export default {
exportCsvPath: {
default: '',
},
+ fullPath: {
+ default: '',
+ },
groupEpicsPath: {
default: '',
},
+ hasAnyIssues: {
+ default: false,
+ },
hasBlockedIssuesFeature: {
default: false,
},
@@ -132,17 +138,14 @@ export default {
hasMultipleIssueAssigneesFeature: {
default: false,
},
- hasProjectIssues: {
- default: false,
- },
initialEmail: {
default: '',
},
- isSignedIn: {
+ isProject: {
default: false,
},
- issuesPath: {
- default: '',
+ isSignedIn: {
+ default: false,
},
jiraIntegrationPath: {
default: '',
@@ -150,9 +153,6 @@ export default {
newIssuePath: {
default: '',
},
- projectPath: {
- default: '',
- },
rssPath: {
default: '',
},
@@ -164,18 +164,16 @@ export default {
},
},
data() {
- const filterTokens = getFilterTokens(window.location.search);
const state = getParameterByName(PARAM_STATE);
const sortKey = getSortKey(getParameterByName(PARAM_SORT));
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
- this.initialFilterTokens = cloneDeep(filterTokens);
-
return {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
- filterTokens,
+ filterTokens: getFilterTokens(window.location.search),
issues: [],
+ issuesCounts: {},
pageInfo: {},
pageParams: getInitialPageParams(sortKey),
showBulkEditSidebar: false,
@@ -189,61 +187,47 @@ export default {
variables() {
return this.queryVariables;
},
- update: ({ project }) => project?.issues.nodes ?? [],
+ update(data) {
+ return data[this.namespace]?.issues.nodes ?? [];
+ },
result({ data }) {
- this.pageInfo = data.project?.issues.pageInfo ?? {};
+ this.pageInfo = data[this.namespace]?.issues.pageInfo ?? {};
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error(error) {
createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error });
},
skip() {
- return !this.hasProjectIssues;
+ return !this.hasAnyIssues;
},
debounce: 200,
},
- countOpened: {
- ...issuesCountSmartQueryBase,
+ issuesCounts: {
+ query: getIssuesCountsQuery,
variables() {
- return {
- ...this.queryVariables,
- state: IssuableStates.Opened,
- };
+ return this.queryVariables;
},
- skip() {
- return !this.hasProjectIssues;
+ update(data) {
+ return data[this.namespace] ?? {};
},
- },
- countClosed: {
- ...issuesCountSmartQueryBase,
- variables() {
- return {
- ...this.queryVariables,
- state: IssuableStates.Closed,
- };
+ error(error) {
+ createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error });
},
skip() {
- return !this.hasProjectIssues;
+ return !this.hasAnyIssues;
},
- },
- countAll: {
- ...issuesCountSmartQueryBase,
- variables() {
- return {
- ...this.queryVariables,
- state: IssuableStates.All,
- };
- },
- skip() {
- return !this.hasProjectIssues;
+ debounce: 200,
+ context: {
+ isSingleRequest: true,
},
},
},
computed: {
queryVariables() {
return {
+ fullPath: this.fullPath,
+ isProject: this.isProject,
isSignedIn: this.isSignedIn,
- projectPath: this.projectPath,
search: this.searchQuery,
sort: this.sortKey,
state: this.state,
@@ -251,6 +235,9 @@ export default {
...this.apiFilterParams,
};
},
+ namespace() {
+ return this.isProject ? ITEM_TYPE.PROJECT : ITEM_TYPE.GROUP;
+ },
hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length;
},
@@ -263,6 +250,9 @@ export default {
isOpenTab() {
return this.state === IssuableStates.Opened;
},
+ showCsvButtons() {
+ return this.isProject && this.isSignedIn;
+ },
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
@@ -405,10 +395,11 @@ export default {
return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
},
tabCounts() {
+ const { openedIssues, closedIssues, allIssues } = this.issuesCounts;
return {
- [IssuableStates.Opened]: this.countOpened,
- [IssuableStates.Closed]: this.countClosed,
- [IssuableStates.All]: this.countAll,
+ [IssuableStates.Opened]: openedIssues?.count,
+ [IssuableStates.Closed]: closedIssues?.count,
+ [IssuableStates.All]: allIssues?.count,
};
},
currentTabCount() {
@@ -465,39 +456,41 @@ export default {
return this.$apollo
.query({
query: searchLabelsQuery,
- variables: { projectPath: this.projectPath, search },
+ variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
- .then(({ data }) => data.project.labels.nodes);
+ .then(({ data }) => data[this.namespace]?.labels.nodes);
},
fetchMilestones(search) {
return this.$apollo
.query({
query: searchMilestonesQuery,
- variables: { projectPath: this.projectPath, search },
+ variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
- .then(({ data }) => data.project.milestones.nodes);
+ .then(({ data }) => data[this.namespace]?.milestones.nodes);
},
fetchIterations(search) {
const id = Number(search);
const variables =
!search || Number.isNaN(id)
- ? { projectPath: this.projectPath, search }
- : { projectPath: this.projectPath, id };
+ ? { fullPath: this.fullPath, search, isProject: this.isProject }
+ : { fullPath: this.fullPath, id, isProject: this.isProject };
return this.$apollo
.query({
query: searchIterationsQuery,
variables,
})
- .then(({ data }) => data.project.iterations.nodes);
+ .then(({ data }) => data[this.namespace]?.iterations.nodes);
},
fetchUsers(search) {
return this.$apollo
.query({
query: searchUsersQuery,
- variables: { projectPath: this.projectPath, search },
+ variables: { fullPath: this.fullPath, search, isProject: this.isProject },
})
- .then(({ data }) => data.project.projectMembers.nodes.map((member) => member.user));
+ .then(({ data }) =>
+ data[this.namespace]?.[`${this.namespace}Members`].nodes.map((member) => member.user),
+ );
},
getExportCsvPathWithQuery() {
return `${this.exportCsvPath}${window.location.search}`;
@@ -578,19 +571,20 @@ export default {
}
return axios
- .put(joinPaths(this.issuesPath, issueToMove.iid, 'reorder'), {
+ .put(joinPaths(issueToMove.webPath, 'reorder'), {
move_before_id: isMovingToBeginning ? null : getIdFromGraphQLId(moveBeforeId),
move_after_id: isMovingToEnd ? null : getIdFromGraphQLId(moveAfterId),
+ group_full_path: this.isProject ? undefined : this.fullPath,
})
.then(() => {
const serializedVariables = JSON.stringify(this.queryVariables);
- this.$apollo.mutate({
+ return this.$apollo.mutate({
mutation: reorderIssuesMutation,
- variables: { oldIndex, newIndex, serializedVariables },
+ variables: { oldIndex, newIndex, namespace: this.namespace, serializedVariables },
});
})
- .catch(() => {
- createFlash({ message: this.$options.i18n.reorderError });
+ .catch((error) => {
+ createFlash({ message: this.$options.i18n.reorderError, captureError: true, error });
});
},
handleSort(sortKey) {
@@ -607,13 +601,13 @@ export default {
</script>
<template>
- <div v-if="hasProjectIssues">
+ <div v-if="hasAnyIssues">
<issuable-list
- :namespace="projectPath"
+ :namespace="fullPath"
recent-searches-storage-key="issues"
:search-input-placeholder="$options.i18n.searchPlaceholder"
:search-tokens="searchTokens"
- :initial-filter-value="initialFilterTokens"
+ :initial-filter-value="filterTokens"
:sort-options="sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
@@ -653,7 +647,7 @@ export default {
:aria-label="$options.i18n.calendarLabel"
/>
<csv-import-export-buttons
- v-if="isSignedIn"
+ v-if="showCsvButtons"
class="gl-md-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
@@ -766,6 +760,7 @@ export default {
{{ $options.i18n.newIssueLabel }}
</gl-button>
<csv-import-export-buttons
+ v-if="showCsvButtons"
class="gl-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
diff --git a/app/assets/javascripts/issues_list/constants.js b/app/assets/javascripts/issues_list/constants.js
index 3f5b0d1feb5..5bdc1bd9f90 100644
--- a/app/assets/javascripts/issues_list/constants.js
+++ b/app/assets/javascripts/issues_list/constants.js
@@ -1,5 +1,3 @@
-import getIssuesCountQuery from 'ee_else_ce/issues_list/queries/get_issues_count.query.graphql';
-import createFlash from '~/flash';
import { __, s__ } from '~/locale';
import {
FILTER_ANY,
@@ -22,6 +20,7 @@ const MILESTONE_DUE = 'milestone_due';
const POPULARITY = 'popularity';
const WEIGHT = 'weight';
const LABEL_PRIORITY = 'label_priority';
+const TITLE = 'title';
export const RELATIVE_POSITION = 'relative_position';
export const LOADING_LIST_ITEMS_LENGTH = 8;
export const PAGE_SIZE = 20;
@@ -43,6 +42,8 @@ export const sortOrderMap = {
relative_position: { order_by: RELATIVE_POSITION, sort: ASC },
weight_desc: { order_by: WEIGHT, sort: DESC },
weight: { order_by: WEIGHT, sort: ASC },
+ title: { order_by: TITLE, sort: ASC },
+ title_desc: { order_by: TITLE, sort: DESC },
};
export const availableSortOptionsJira = [
@@ -146,6 +147,8 @@ export const POPULARITY_DESC = 'POPULARITY_DESC';
export const PRIORITY_ASC = 'PRIORITY_ASC';
export const PRIORITY_DESC = 'PRIORITY_DESC';
export const RELATIVE_POSITION_ASC = 'RELATIVE_POSITION_ASC';
+export const TITLE_ASC = 'TITLE_ASC';
+export const TITLE_DESC = 'TITLE_DESC';
export const UPDATED_ASC = 'UPDATED_ASC';
export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
@@ -163,6 +166,7 @@ const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc';
const POPULARITY_ASC_SORT = 'popularity_asc';
const WEIGHT_DESC_SORT = 'weight_desc';
const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc';
+const TITLE_DESC_SORT = 'title_desc';
export const urlSortParams = {
[PRIORITY_ASC]: PRIORITY_ASC_SORT,
@@ -183,6 +187,8 @@ export const urlSortParams = {
[WEIGHT_ASC]: WEIGHT,
[WEIGHT_DESC]: WEIGHT_DESC_SORT,
[BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT,
+ [TITLE_ASC]: TITLE,
+ [TITLE_DESC]: TITLE_DESC_SORT,
};
export const MAX_LIST_SIZE = 10;
@@ -351,15 +357,3 @@ export const filters = {
},
},
};
-
-export const issuesCountSmartQueryBase = {
- query: getIssuesCountQuery,
- context: {
- isSingleRequest: true,
- },
- update: ({ project }) => project?.issues.count,
- error(error) {
- createFlash({ message: i18n.errorFetchingCounts, captureError: true, error });
- },
- debounce: 200,
-};
diff --git a/app/assets/javascripts/issues_list/index.js b/app/assets/javascripts/issues_list/index.js
index dcc7ee72273..e89e3e8e681 100644
--- a/app/assets/javascripts/issues_list/index.js
+++ b/app/assets/javascripts/issues_list/index.js
@@ -85,17 +85,17 @@ export function mountIssuesListApp() {
const resolvers = {
Mutation: {
- reorderIssues: (_, { oldIndex, newIndex, serializedVariables }, { cache }) => {
+ reorderIssues: (_, { oldIndex, newIndex, namespace, serializedVariables }, { cache }) => {
const variables = JSON.parse(serializedVariables);
const sourceData = cache.readQuery({ query: getIssuesQuery, variables });
const data = produce(sourceData, (draftData) => {
- const issues = draftData.project.issues.nodes.slice();
+ const issues = draftData[namespace].issues.nodes.slice();
const issueToMove = issues[oldIndex];
issues.splice(oldIndex, 1);
issues.splice(newIndex, 0, issueToMove);
- draftData.project.issues.nodes = issues;
+ draftData[namespace].issues.nodes = issues;
});
cache.writeQuery({ query: getIssuesQuery, variables, data });
@@ -118,23 +118,23 @@ export function mountIssuesListApp() {
emailsHelpPagePath,
emptyStateSvgPath,
exportCsvPath,
+ fullPath,
groupEpicsPath,
+ hasAnyIssues,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
hasIterationsFeature,
hasMultipleIssueAssigneesFeature,
- hasProjectIssues,
importCsvIssuesPath,
initialEmail,
+ isProject,
isSignedIn,
- issuesPath,
jiraIntegrationPath,
markdownHelpPath,
maxAttachmentSize,
newIssuePath,
projectImportJiraPath,
- projectPath,
quickActionsHelpPath,
resetPath,
rssPath,
@@ -150,18 +150,18 @@ export function mountIssuesListApp() {
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
emptyStateSvgPath,
+ fullPath,
groupEpicsPath,
+ hasAnyIssues: parseBoolean(hasAnyIssues),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
- hasProjectIssues: parseBoolean(hasProjectIssues),
+ isProject: parseBoolean(isProject),
isSignedIn: parseBoolean(isSignedIn),
- issuesPath,
jiraIntegrationPath,
newIssuePath,
- projectPath,
rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink),
signInPath,
@@ -172,9 +172,9 @@ export function mountIssuesListApp() {
importCsvIssuesPath,
maxAttachmentSize,
projectImportJiraPath,
- showExportButton: parseBoolean(hasProjectIssues),
+ showExportButton: parseBoolean(hasAnyIssues),
showImportButton: parseBoolean(canImportIssues),
- showLabel: !parseBoolean(hasProjectIssues),
+ showLabel: !parseBoolean(hasAnyIssues),
// For IssuableByEmail component
emailsHelpPagePath,
initialEmail,
diff --git a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
index 30a01b4c3b0..6df72cf6596 100644
--- a/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/get_issues.query.graphql
@@ -1,9 +1,10 @@
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./issue.fragment.graphql"
-query getProjectIssues(
+query getIssues(
+ $isProject: Boolean = false
$isSignedIn: Boolean = false
- $projectPath: ID!
+ $fullPath: ID!
$search: String
$sort: IssueSort
$state: IssuableState
@@ -20,7 +21,35 @@ query getProjectIssues(
$firstPageSize: Int
$lastPageSize: Int
) {
- project(fullPath: $projectPath) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ issues(
+ includeSubgroups: true
+ search: $search
+ sort: $sort
+ state: $state
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ types: $types
+ not: $not
+ before: $beforeCursor
+ after: $afterCursor
+ first: $firstPageSize
+ last: $lastPageSize
+ ) {
+ pageInfo {
+ ...PageInfo
+ }
+ nodes {
+ ...IssueFragment
+ reference(full: true)
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
issues(
search: $search
sort: $sort
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql
deleted file mode 100644
index e6896131da9..00000000000
--- a/app/assets/javascripts/issues_list/queries/get_issues_count.query.graphql
+++ /dev/null
@@ -1,30 +0,0 @@
-query getProjectIssuesCount(
- $projectPath: ID!
- $search: String
- $state: IssuableState
- $assigneeId: String
- $assigneeUsernames: [String!]
- $authorUsername: String
- $labelName: [String]
- $milestoneTitle: [String]
- $milestoneWildcardId: MilestoneWildcardId
- $types: [IssueType!]
- $not: NegatedIssueFilterInput
-) {
- project(fullPath: $projectPath) {
- issues(
- search: $search
- state: $state
- assigneeId: $assigneeId
- assigneeUsernames: $assigneeUsernames
- authorUsername: $authorUsername
- labelName: $labelName
- milestoneTitle: $milestoneTitle
- milestoneWildcardId: $milestoneWildcardId
- types: $types
- not: $not
- ) {
- count
- }
- }
-}
diff --git a/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql
new file mode 100644
index 00000000000..7bcdbbb28fc
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/get_issues_counts.query.graphql
@@ -0,0 +1,105 @@
+query getIssuesCount(
+ $isProject: Boolean = false
+ $fullPath: ID!
+ $search: String
+ $assigneeId: String
+ $assigneeUsernames: [String!]
+ $authorUsername: String
+ $labelName: [String]
+ $milestoneTitle: [String]
+ $milestoneWildcardId: MilestoneWildcardId
+ $types: [IssueType!]
+ $not: NegatedIssueFilterInput
+) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ openedIssues: issues(
+ includeSubgroups: true
+ state: opened
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ types: $types
+ not: $not
+ ) {
+ count
+ }
+ closedIssues: issues(
+ includeSubgroups: true
+ state: closed
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ types: $types
+ not: $not
+ ) {
+ count
+ }
+ allIssues: issues(
+ includeSubgroups: true
+ state: all
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ types: $types
+ not: $not
+ ) {
+ count
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ openedIssues: issues(
+ state: opened
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ types: $types
+ not: $not
+ ) {
+ count
+ }
+ closedIssues: issues(
+ state: closed
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ types: $types
+ not: $not
+ ) {
+ count
+ }
+ allIssues: issues(
+ state: all
+ search: $search
+ assigneeId: $assigneeId
+ assigneeUsernames: $assigneeUsernames
+ authorUsername: $authorUsername
+ labelName: $labelName
+ milestoneTitle: $milestoneTitle
+ milestoneWildcardId: $milestoneWildcardId
+ types: $types
+ not: $not
+ ) {
+ count
+ }
+ }
+}
diff --git a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
index 633b06eced8..9c46cb3ef64 100644
--- a/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
+++ b/app/assets/javascripts/issues_list/queries/issue.fragment.graphql
@@ -13,6 +13,7 @@ fragment IssueFragment on Issue {
updatedAt
upvotes
userDiscussionsCount @include(if: $isSignedIn)
+ webPath
webUrl
assignees {
nodes {
diff --git a/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql
new file mode 100644
index 00000000000..78a368089a8
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/iteration.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Iteration on Iteration {
+ id
+ title
+}
diff --git a/app/assets/javascripts/issues_list/queries/label.fragment.graphql b/app/assets/javascripts/issues_list/queries/label.fragment.graphql
new file mode 100644
index 00000000000..bb1d8f1ac9b
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/label.fragment.graphql
@@ -0,0 +1,6 @@
+fragment Label on Label {
+ id
+ color
+ textColor
+ title
+}
diff --git a/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql b/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql
new file mode 100644
index 00000000000..3cdf69bf585
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/milestone.fragment.graphql
@@ -0,0 +1,4 @@
+fragment Milestone on Milestone {
+ id
+ title
+}
diff --git a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql b/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql
index 5927e3e83c7..160026a4742 100644
--- a/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql
+++ b/app/assets/javascripts/issues_list/queries/reorder_issues.mutation.graphql
@@ -1,7 +1,13 @@
-mutation reorderIssues($oldIndex: Int, $newIndex: Int, $serializedVariables: String) {
+mutation reorderIssues(
+ $oldIndex: Int
+ $newIndex: Int
+ $namespace: String
+ $serializedVariables: String
+) {
reorderIssues(
oldIndex: $oldIndex
newIndex: $newIndex
+ namespace: $namespace
serializedVariables: $serializedVariables
) @client
}
diff --git a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql
index 11d9dcea573..93600c62905 100644
--- a/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_iterations.query.graphql
@@ -1,9 +1,17 @@
-query searchIterations($projectPath: ID!, $search: String, $id: ID) {
- project(fullPath: $projectPath) {
- iterations(title: $search, id: $id) {
+#import "./iteration.fragment.graphql"
+
+query searchIterations($fullPath: ID!, $search: String, $id: ID, $isProject: Boolean = false) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ iterations(title: $search, id: $id, includeAncestors: true) {
nodes {
- id
- title
+ ...Iteration
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
+ iterations(title: $search, id: $id, includeAncestors: true) {
+ nodes {
+ ...Iteration
}
}
}
diff --git a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
index de884e1221c..1515bd91da3 100644
--- a/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_labels.query.graphql
@@ -1,11 +1,17 @@
-query searchLabels($projectPath: ID!, $search: String) {
- project(fullPath: $projectPath) {
+#import "./label.fragment.graphql"
+
+query searchLabels($fullPath: ID!, $search: String, $isProject: Boolean = false) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ labels(searchTerm: $search, includeAncestorGroups: true, includeDescendantGroups: true) {
+ nodes {
+ ...Label
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
labels(searchTerm: $search, includeAncestorGroups: true) {
nodes {
- id
- color
- textColor
- title
+ ...Label
}
}
}
diff --git a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
index 91f74fd220b..8c6c50e9dc2 100644
--- a/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_milestones.query.graphql
@@ -1,9 +1,17 @@
-query searchMilestones($projectPath: ID!, $search: String) {
- project(fullPath: $projectPath) {
+#import "./milestone.fragment.graphql"
+
+query searchMilestones($fullPath: ID!, $search: String, $isProject: Boolean = false) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ milestones(searchTitle: $search, includeAncestors: true, includeDescendants: true) {
+ nodes {
+ ...Milestone
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
milestones(searchTitle: $search, includeAncestors: true) {
nodes {
- id
- title
+ ...Milestone
}
}
}
diff --git a/app/assets/javascripts/issues_list/queries/search_users.query.graphql b/app/assets/javascripts/issues_list/queries/search_users.query.graphql
index 953157cfe3a..0211fc66235 100644
--- a/app/assets/javascripts/issues_list/queries/search_users.query.graphql
+++ b/app/assets/javascripts/issues_list/queries/search_users.query.graphql
@@ -1,12 +1,20 @@
-query searchUsers($projectPath: ID!, $search: String) {
- project(fullPath: $projectPath) {
+#import "./user.fragment.graphql"
+
+query searchUsers($fullPath: ID!, $search: String, $isProject: Boolean = false) {
+ group(fullPath: $fullPath) @skip(if: $isProject) {
+ groupMembers(search: $search) {
+ nodes {
+ user {
+ ...User
+ }
+ }
+ }
+ }
+ project(fullPath: $fullPath) @include(if: $isProject) {
projectMembers(search: $search) {
nodes {
user {
- id
- avatarUrl
- name
- username
+ ...User
}
}
}
diff --git a/app/assets/javascripts/issues_list/queries/user.fragment.graphql b/app/assets/javascripts/issues_list/queries/user.fragment.graphql
new file mode 100644
index 00000000000..3e5bc0f7b93
--- /dev/null
+++ b/app/assets/javascripts/issues_list/queries/user.fragment.graphql
@@ -0,0 +1,6 @@
+fragment User on User {
+ id
+ avatarUrl
+ name
+ username
+}
diff --git a/app/assets/javascripts/jira_connect/subscriptions/utils.js b/app/assets/javascripts/jira_connect/subscriptions/utils.js
index ecd1a31339a..ed7a9484a81 100644
--- a/app/assets/javascripts/jira_connect/subscriptions/utils.js
+++ b/app/assets/javascripts/jira_connect/subscriptions/utils.js
@@ -7,7 +7,7 @@ const isFunction = (fn) => typeof fn === 'function';
* Persist alert data to localStorage.
*/
export const persistAlert = ({ title, message, linkUrl, variant } = {}) => {
- if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (!AccessorUtilities.canUseLocalStorage()) {
return;
}
@@ -19,7 +19,7 @@ export const persistAlert = ({ title, message, linkUrl, variant } = {}) => {
* Return alert data from localStorage.
*/
export const retrieveAlert = () => {
- if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (!AccessorUtilities.canUseLocalStorage()) {
return null;
}
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index fa9ee56c049..059772e8cb9 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -5,7 +5,7 @@ import { throttle, isEmpty } from 'lodash';
import { mapGetters, mapState, mapActions } from 'vuex';
import CodeQualityWalkthrough from '~/code_quality_walkthrough/components/step.vue';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
-import { sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import delayedJobMixin from '../mixins/delayed_job_mixin';
import EmptyState from './empty_state.vue';
@@ -126,6 +126,9 @@ export default {
shouldRenderCodeQualityWalkthrough() {
return this.job.status.group === 'failed-with-warnings';
},
+ itemName() {
+ return sprintf(__('Job %{jobName}'), { jobName: this.job.name });
+ },
},
watch: {
// Once the job log is loaded,
@@ -205,12 +208,11 @@ export default {
<div class="build-header top-area">
<ci-header
:status="job.status"
- :item-id="job.id"
:time="headerTime"
:user="job.user"
:has-sidebar-button="true"
:should-render-triggered-label="shouldRenderTriggeredLabel"
- :item-name="__('Job')"
+ :item-name="itemName"
@clickedSidebarButton="toggleSidebar"
/>
</div>
diff --git a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
index 376482b0319..6b3a4424a5b 100644
--- a/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
+++ b/app/assets/javascripts/jobs/components/table/cells/actions_cell.vue
@@ -1,14 +1,195 @@
<script>
+import { GlButton, GlButtonGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+import {
+ ACTIONS_DOWNLOAD_ARTIFACTS,
+ ACTIONS_START_NOW,
+ ACTIONS_UNSCHEDULE,
+ ACTIONS_PLAY,
+ ACTIONS_RETRY,
+ CANCEL,
+ GENERIC_ERROR,
+ JOB_SCHEDULED,
+ PLAY_JOB_CONFIRMATION_MESSAGE,
+ RUN_JOB_NOW_HEADER_TITLE,
+} from '../constants';
+import eventHub from '../event_hub';
+import cancelJobMutation from '../graphql/mutations/job_cancel.mutation.graphql';
+import playJobMutation from '../graphql/mutations/job_play.mutation.graphql';
+import retryJobMutation from '../graphql/mutations/job_retry.mutation.graphql';
+import unscheduleJobMutation from '../graphql/mutations/job_unschedule.mutation.graphql';
+
export default {
+ ACTIONS_DOWNLOAD_ARTIFACTS,
+ ACTIONS_START_NOW,
+ ACTIONS_UNSCHEDULE,
+ ACTIONS_PLAY,
+ ACTIONS_RETRY,
+ CANCEL,
+ GENERIC_ERROR,
+ PLAY_JOB_CONFIRMATION_MESSAGE,
+ RUN_JOB_NOW_HEADER_TITLE,
+ jobRetry: 'jobRetry',
+ jobCancel: 'jobCancel',
+ jobPlay: 'jobPlay',
+ jobUnschedule: 'jobUnschedule',
+ playJobModalId: 'play-job-modal',
+ components: {
+ GlButton,
+ GlButtonGroup,
+ GlCountdown,
+ GlModal,
+ GlSprintf,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ inject: {
+ admin: {
+ default: false,
+ },
+ },
props: {
job: {
type: Object,
required: true,
},
},
+ computed: {
+ artifactDownloadPath() {
+ return this.job.artifacts?.nodes[0]?.downloadPath;
+ },
+ canReadJob() {
+ return this.job.userPermissions?.readBuild;
+ },
+ isActive() {
+ return this.job.active;
+ },
+ manualJobPlayable() {
+ return this.job.playable && !this.admin && this.job.manualJob;
+ },
+ isRetryable() {
+ return this.job.retryable;
+ },
+ isScheduled() {
+ return this.job.status === JOB_SCHEDULED;
+ },
+ scheduledAt() {
+ return this.job.scheduledAt;
+ },
+ currentJobActionPath() {
+ return this.job.detailedStatus?.action?.path;
+ },
+ currentJobMethod() {
+ return this.job.detailedStatus?.action?.method;
+ },
+ shouldDisplayArtifacts() {
+ return this.job.userPermissions?.readJobArtifacts && this.job.artifacts?.nodes.length > 0;
+ },
+ },
+ methods: {
+ async postJobAction(name, mutation) {
+ try {
+ const {
+ data: {
+ [name]: { errors },
+ },
+ } = await this.$apollo.mutate({
+ mutation,
+ variables: { id: this.job.id },
+ });
+ if (errors.length > 0) {
+ this.reportFailure();
+ } else {
+ eventHub.$emit('jobActionPerformed');
+ }
+ } catch {
+ this.reportFailure();
+ }
+ },
+ reportFailure() {
+ const toastProps = {
+ text: this.$options.GENERIC_ERROR,
+ variant: 'danger',
+ };
+
+ this.$toast.show(toastProps.text, {
+ variant: toastProps.variant,
+ });
+ },
+ cancelJob() {
+ this.postJobAction(this.$options.jobCancel, cancelJobMutation);
+ },
+ retryJob() {
+ this.postJobAction(this.$options.jobRetry, retryJobMutation);
+ },
+ playJob() {
+ this.postJobAction(this.$options.jobPlay, playJobMutation);
+ },
+ unscheduleJob() {
+ this.postJobAction(this.$options.jobUnschedule, unscheduleJobMutation);
+ },
+ },
};
</script>
<template>
- <div></div>
+ <gl-button-group>
+ <template v-if="canReadJob">
+ <gl-button v-if="isActive" icon="cancel" :title="$options.CANCEL" @click="cancelJob()" />
+ <template v-else-if="isScheduled">
+ <gl-button icon="planning" disabled data-testid="countdown">
+ <gl-countdown :end-date-string="scheduledAt" />
+ </gl-button>
+ <gl-button
+ v-gl-modal-directive="$options.playJobModalId"
+ icon="play"
+ :title="$options.ACTIONS_START_NOW"
+ data-testid="play-scheduled"
+ />
+ <gl-modal
+ :modal-id="$options.playJobModalId"
+ :title="$options.RUN_JOB_NOW_HEADER_TITLE"
+ @primary="playJob()"
+ >
+ <gl-sprintf :message="$options.PLAY_JOB_CONFIRMATION_MESSAGE">
+ <template #job_name>{{ job.name }}</template>
+ </gl-sprintf>
+ </gl-modal>
+ <gl-button
+ icon="time-out"
+ :title="$options.ACTIONS_UNSCHEDULE"
+ data-testid="unschedule"
+ @click="unscheduleJob()"
+ />
+ </template>
+ <template v-else>
+ <!--Note: This is the manual job play button -->
+ <gl-button
+ v-if="manualJobPlayable"
+ icon="play"
+ :title="$options.ACTIONS_PLAY"
+ data-testid="play"
+ @click="playJob()"
+ />
+ <gl-button
+ v-else-if="isRetryable"
+ icon="repeat"
+ :title="$options.ACTIONS_RETRY"
+ :method="currentJobMethod"
+ data-testid="retry"
+ @click="retryJob()"
+ />
+ </template>
+ </template>
+ <gl-button
+ v-if="shouldDisplayArtifacts"
+ icon="download"
+ :title="$options.ACTIONS_DOWNLOAD_ARTIFACTS"
+ :href="artifactDownloadPath"
+ rel="nofollow"
+ download
+ data-testid="download-artifacts"
+ />
+ </gl-button-group>
</template>
diff --git a/app/assets/javascripts/jobs/components/table/constants.js b/app/assets/javascripts/jobs/components/table/constants.js
index 7e973a34e5c..e5d1bc01cbf 100644
--- a/app/assets/javascripts/jobs/components/table/constants.js
+++ b/app/assets/javascripts/jobs/components/table/constants.js
@@ -1,3 +1,5 @@
+import { s__, __ } from '~/locale';
+
export const GRAPHQL_PAGE_SIZE = 30;
export const initialPaginationState = {
@@ -7,3 +9,24 @@ export const initialPaginationState = {
first: GRAPHQL_PAGE_SIZE,
last: null,
};
+
+/* Error constants */
+export const POST_FAILURE = 'post_failure';
+export const DEFAULT = 'default';
+
+/* Job Status Constants */
+export const JOB_SCHEDULED = 'SCHEDULED';
+
+/* i18n */
+export const ACTIONS_DOWNLOAD_ARTIFACTS = __('Download artifacts');
+export const ACTIONS_START_NOW = s__('DelayedJobs|Start now');
+export const ACTIONS_UNSCHEDULE = s__('DelayedJobs|Unschedule');
+export const ACTIONS_PLAY = __('Play');
+export const ACTIONS_RETRY = __('Retry');
+
+export const CANCEL = __('Cancel');
+export const GENERIC_ERROR = __('An error occurred while making the request.');
+export const PLAY_JOB_CONFIRMATION_MESSAGE = s__(
+ `DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes.`,
+);
+export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?');
diff --git a/app/assets/javascripts/jobs/components/table/event_hub.js b/app/assets/javascripts/jobs/components/table/event_hub.js
new file mode 100644
index 00000000000..e31806ad199
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/event_hub.js
@@ -0,0 +1,3 @@
+import createEventHub from '~/helpers/event_hub_factory';
+
+export default createEventHub();
diff --git a/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql b/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql
new file mode 100644
index 00000000000..06b065a86ce
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/fragments/job.fragment.graphql
@@ -0,0 +1,3 @@
+fragment Job on CiJob {
+ id
+}
diff --git a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql b/app/assets/javascripts/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql
new file mode 100644
index 00000000000..20935514d51
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/mutations/job_cancel.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/job.fragment.graphql"
+
+mutation cancelJob($id: CiBuildID!) {
+ jobCancel(input: { id: $id }) {
+ job {
+ ...Job
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_play.mutation.graphql b/app/assets/javascripts/jobs/components/table/graphql/mutations/job_play.mutation.graphql
new file mode 100644
index 00000000000..c94b045ac40
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/mutations/job_play.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/job.fragment.graphql"
+
+mutation playJob($id: CiBuildID!) {
+ jobPlay(input: { id: $id }) {
+ job {
+ ...Job
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_retry.mutation.graphql b/app/assets/javascripts/jobs/components/table/graphql/mutations/job_retry.mutation.graphql
new file mode 100644
index 00000000000..6e51f9a20fa
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/mutations/job_retry.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/job.fragment.graphql"
+
+mutation retryJob($id: CiBuildID!) {
+ jobRetry(input: { id: $id }) {
+ job {
+ ...Job
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql b/app/assets/javascripts/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql
new file mode 100644
index 00000000000..8be8c42f3c3
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/table/graphql/mutations/job_unschedule.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/job.fragment.graphql"
+
+mutation unscheduleJob($id: CiBuildID!) {
+ jobUnschedule(input: { id: $id }) {
+ job {
+ ...Job
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
index 68c6584cda6..c8763d4767e 100644
--- a/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
+++ b/app/assets/javascripts/jobs/components/table/graphql/queries/get_jobs.query.graphql
@@ -69,6 +69,7 @@ query getJobs(
stuck
userPermissions {
readBuild
+ readJobArtifacts
}
}
}
diff --git a/app/assets/javascripts/jobs/components/table/index.js b/app/assets/javascripts/jobs/components/table/index.js
index 05d6ebfd6d6..f24daf90815 100644
--- a/app/assets/javascripts/jobs/components/table/index.js
+++ b/app/assets/javascripts/jobs/components/table/index.js
@@ -1,9 +1,12 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(VueApollo);
+Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -22,6 +25,7 @@ export default (containerId = 'js-jobs-table') => {
jobStatuses,
pipelineEditorPath,
emptyStateSvgPath,
+ admin,
} = containerEl.dataset;
return new Vue({
@@ -33,6 +37,7 @@ export default (containerId = 'js-jobs-table') => {
pipelineEditorPath,
jobStatuses: JSON.parse(jobStatuses),
jobCounts: JSON.parse(jobCounts),
+ admin: parseBoolean(admin),
},
render(createElement) {
return createElement(JobsTableApp);
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue
index 076c0e78b11..298c99c4162 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue
@@ -141,7 +141,7 @@ export default {
</template>
<template #cell(actions)="{ item }">
- <actions-cell :job="item" />
+ <actions-cell class="gl-float-right" :job="item" />
</template>
</gl-table>
</template>
diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
index 2061b1f1eb2..c786d35ac68 100644
--- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
+++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue
@@ -2,6 +2,7 @@
import { GlAlert, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
import { __ } from '~/locale';
import { GRAPHQL_PAGE_SIZE, initialPaginationState } from './constants';
+import eventHub from './event_hub';
import GetJobs from './graphql/queries/get_jobs.query.graphql';
import JobsTable from './jobs_table.vue';
import JobsTableEmptyState from './jobs_table_empty_state.vue';
@@ -74,7 +75,16 @@ export default {
return Boolean(this.prevPage || this.nextPage) && !this.$apollo.loading;
},
},
+ mounted() {
+ eventHub.$on('jobActionPerformed', this.handleJobAction);
+ },
+ beforeDestroy() {
+ eventHub.$off('jobActionPerformed', this.handleJobAction);
+ },
methods: {
+ handleJobAction() {
+ this.$apollo.queries.jobs.refetch({ statuses: this.scope });
+ },
fetchJobsByStatus(scope) {
this.scope = scope;
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index a62ab301227..68019a35dbb 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,13 +1,11 @@
/* eslint-disable func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-empty */
/* global Issuable */
-/* global ListLabel */
import $ from 'jquery';
import { difference, isEqual, escape, sortBy, template, union } from 'lodash';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import IssuableBulkUpdateActions from '~/issuable_bulk_update_sidebar/issuable_bulk_update_actions';
import { isScopedLabel } from '~/lib/utils/common_utils';
-import boardsStore from './boards/stores/boards_store';
import CreateLabelDropdown from './create_label';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
@@ -43,7 +41,6 @@ export default class LabelsSelect {
const $form = $dropdown.closest('form, .js-issuable-update');
const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
const $value = $block.find('.value');
- const $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
const $loading = $block.find('.block-loading').addClass('gl-display-none');
const fieldName = $dropdown.data('fieldName');
let initialSelected = $selectbox
@@ -341,15 +338,11 @@ export default class LabelsSelect {
}
},
multiSelect: $dropdown.hasClass('js-multiselect'),
- vue: $dropdown.hasClass('js-issue-board-sidebar'),
+ vue: false,
clicked(clickEvent) {
- const { $el, e, isMarking } = clickEvent;
+ const { e, isMarking } = clickEvent;
const label = clickEvent.selectedObj;
- const hideLoader = () => {
- $loading.addClass('gl-display-none');
- };
-
const page = $('body').attr('data-page');
const isIssueIndex = page === 'projects:issues:index';
const isMRIndex = page === 'projects:merge_requests:index';
@@ -375,40 +368,6 @@ export default class LabelsSelect {
}
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
- } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- if ($el.hasClass('is-active')) {
- boardsStore.detail.issue.labels.push(
- new ListLabel({
- id: label.id,
- title: label.title,
- color: label.color,
- textColor: '#fff',
- }),
- );
- } else {
- let { labels } = boardsStore.detail.issue;
- labels = labels.filter((selectedLabel) => selectedLabel.id !== label.id);
- boardsStore.detail.issue.labels = labels;
- }
-
- $loading.removeClass('gl-display-none');
- const oldLabels = boardsStore.detail.issue.labels;
-
- boardsStore.detail.issue
- .update($dropdown.attr('data-issue-update'))
- .then(() => {
- if (isScopedLabel(label)) {
- const prevIds = oldLabels.map((label) => label.id);
- const newIds = boardsStore.detail.issue.labels.map((label) => label.id);
- const differentIds = prevIds.filter((x) => !newIds.includes(x));
- $dropdown.data('marked', newIds);
- $dropdownMenu
- .find(differentIds.map((id) => `[data-label-id="${id}"]`).join(','))
- .removeClass('is-active');
- }
- })
- .then(hideLoader)
- .catch(hideLoader);
} else if (handleClick) {
e.preventDefault();
handleClick(label);
@@ -419,13 +378,6 @@ export default class LabelsSelect {
}
}
},
- opened() {
- if ($dropdown.hasClass('js-issue-board-sidebar')) {
- const previousSelection = $dropdown.attr('data-selected');
- this.selected = previousSelection ? previousSelection.split(',') : [];
- $dropdown.data('deprecatedJQueryDropdown').updateLabel();
- }
- },
preserveContext: true,
});
diff --git a/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js b/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js
deleted file mode 100644
index 305d130f10c..00000000000
--- a/app/assets/javascripts/learn_gitlab/track_learn_gitlab.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import Tracking from '~/tracking';
-
-export default function trackLearnGitlab(learnGitlabA) {
- Tracking.event('projects:learn_gitlab:index', 'page_init', {
- label: 'learn_gitlab',
- property: learnGitlabA
- ? 'Growth::Conversion::Experiment::LearnGitLabA'
- : 'Growth::Activation::Experiment::LearnGitLabB',
- });
-}
diff --git a/app/assets/javascripts/lib/apollo/instrumentation_link.js b/app/assets/javascripts/lib/apollo/instrumentation_link.js
new file mode 100644
index 00000000000..2ab364557b8
--- /dev/null
+++ b/app/assets/javascripts/lib/apollo/instrumentation_link.js
@@ -0,0 +1,29 @@
+import { ApolloLink } from 'apollo-link';
+import { memoize } from 'lodash';
+
+export const FEATURE_CATEGORY_HEADER = 'x-gitlab-feature-category';
+
+/**
+ * Returns the ApolloLink (or null) used to add instrumentation metadata to the GraphQL request.
+ *
+ * - The result will be null if the `feature_category` cannot be found.
+ * - The result is memoized since the `feature_category` is the same for the entire page.
+ */
+export const getInstrumentationLink = memoize(() => {
+ const { feature_category: featureCategory } = gon;
+
+ if (!featureCategory) {
+ return null;
+ }
+
+ return new ApolloLink((operation, forward) => {
+ operation.setContext(({ headers = {} }) => ({
+ headers: {
+ ...headers,
+ [FEATURE_CATEGORY_HEADER]: featureCategory,
+ },
+ }));
+
+ return forward(operation);
+ });
+});
diff --git a/app/assets/javascripts/lib/dompurify.js b/app/assets/javascripts/lib/dompurify.js
index a026f76e51b..d421d66981e 100644
--- a/app/assets/javascripts/lib/dompurify.js
+++ b/app/assets/javascripts/lib/dompurify.js
@@ -3,7 +3,7 @@ import { getBaseURL, relativePathToAbsolute } from '~/lib/utils/url_utility';
const defaultConfig = {
// Safely allow SVG <use> tags
- ADD_TAGS: ['use'],
+ ADD_TAGS: ['use', 'gl-emoji'],
// Prevent possible XSS attacks with data-* attributes used by @rails/ujs
// See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1421
FORBID_ATTR: ['data-remote', 'data-url', 'data-type', 'data-method'],
@@ -16,7 +16,7 @@ const getAllowedIconUrls = (gon = window.gon) =>
const isUrlAllowed = (url) => getAllowedIconUrls().some((allowedUrl) => url.startsWith(allowedUrl));
const isHrefSafe = (url) =>
- isUrlAllowed(url) || isUrlAllowed(relativePathToAbsolute(url, getBaseURL()));
+ isUrlAllowed(url) || isUrlAllowed(relativePathToAbsolute(url, getBaseURL())) || url.match(/^#/);
const removeUnsafeHref = (node, attr) => {
if (!node.hasAttribute(attr)) {
@@ -52,4 +52,4 @@ addHook('afterSanitizeAttributes', (node) => {
}
});
-export const sanitize = (val, config = defaultConfig) => dompurifySanitize(val, config);
+export const sanitize = (val, config) => dompurifySanitize(val, { ...defaultConfig, ...config });
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 0804213cafa..b96a55fe116 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -10,6 +10,7 @@ import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
import csrf from '~/lib/utils/csrf';
import { objectToQuery, queryToObject } from '~/lib/utils/url_utility';
import PerformanceBarService from '~/performance_bar/services/performance_bar_service';
+import { getInstrumentationLink } from './apollo/instrumentation_link';
export const fetchPolicies = {
CACHE_FIRST: 'cache-first',
@@ -140,14 +141,17 @@ export default (resolvers = {}, config = {}) => {
const appLink = ApolloLink.split(
hasSubscriptionOperation,
new ActionCableLink(),
- ApolloLink.from([
- requestCounterLink,
- performanceBarLink,
- new StartupJSLink(),
- apolloCaptchaLink,
- uploadsLink,
- requestLink,
- ]),
+ ApolloLink.from(
+ [
+ getInstrumentationLink(),
+ requestCounterLink,
+ performanceBarLink,
+ new StartupJSLink(),
+ apolloCaptchaLink,
+ uploadsLink,
+ requestLink,
+ ].filter(Boolean),
+ ),
);
return new ApolloClient({
diff --git a/app/assets/javascripts/lib/logger/hello.js b/app/assets/javascripts/lib/logger/hello.js
new file mode 100644
index 00000000000..18fa35ab55b
--- /dev/null
+++ b/app/assets/javascripts/lib/logger/hello.js
@@ -0,0 +1,16 @@
+const HANDSHAKE = String.fromCodePoint(0x1f91d);
+const MAG = String.fromCodePoint(0x1f50e);
+
+export const logHello = () => {
+ // eslint-disable-next-line no-console
+ console.log(
+ `%cWelcome to GitLab!%c
+
+Does this page need fixes or improvements? Open an issue or contribute a merge request to help make GitLab more lovable. At GitLab, everyone can contribute!
+
+${HANDSHAKE} Contribute to GitLab: https://about.gitlab.com/community/contribute/
+${MAG} Create a new GitLab issue: https://gitlab.com/gitlab-org/gitlab/-/issues/new`,
+ `padding-top: 0.5em; font-size: 2em;`,
+ 'padding-bottom: 0.5em;',
+ );
+};
diff --git a/app/assets/javascripts/lib/logger/hello_deferred.js b/app/assets/javascripts/lib/logger/hello_deferred.js
new file mode 100644
index 00000000000..ce1dd91cb37
--- /dev/null
+++ b/app/assets/javascripts/lib/logger/hello_deferred.js
@@ -0,0 +1,5 @@
+export const logHelloDeferred = async () => {
+ const { logHello } = await import(/* webpackChunkName: 'hello' */ './hello');
+
+ logHello();
+};
diff --git a/app/assets/javascripts/lib/logger/index.js b/app/assets/javascripts/lib/logger/index.js
new file mode 100644
index 00000000000..0f5353fcbed
--- /dev/null
+++ b/app/assets/javascripts/lib/logger/index.js
@@ -0,0 +1,6 @@
+/* eslint-disable no-console */
+export const LOG_PREFIX = '[gitlab]';
+
+export const logError = (message = '', ...args) => {
+ console.error(LOG_PREFIX, `${message}\n`, ...args);
+};
diff --git a/app/assets/javascripts/lib/utils/accessor.js b/app/assets/javascripts/lib/utils/accessor.js
index 39cffedcac6..d4a6d70c62c 100644
--- a/app/assets/javascripts/lib/utils/accessor.js
+++ b/app/assets/javascripts/lib/utils/accessor.js
@@ -1,4 +1,4 @@
-function isPropertyAccessSafe(base, property) {
+function canAccessProperty(base, property) {
let safe;
try {
@@ -10,7 +10,7 @@ function isPropertyAccessSafe(base, property) {
return safe;
}
-function isFunctionCallSafe(base, functionName, ...args) {
+function canCallFunction(base, functionName, ...args) {
let safe = true;
try {
@@ -22,16 +22,28 @@ function isFunctionCallSafe(base, functionName, ...args) {
return safe;
}
-function isLocalStorageAccessSafe() {
+/**
+ * Determines if `window.localStorage` is available and
+ * can be written to and read from.
+ *
+ * Important: This is not a guarantee that
+ * `localStorage.setItem` will work in all cases.
+ *
+ * `setItem` can still throw exceptions and should be
+ * surrounded with a try/catch where used.
+ *
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem#exceptions
+ */
+function canUseLocalStorage() {
let safe;
- const TEST_KEY = 'isLocalStorageAccessSafe';
+ const TEST_KEY = 'canUseLocalStorage';
const TEST_VALUE = 'true';
- safe = isPropertyAccessSafe(window, 'localStorage');
+ safe = canAccessProperty(window, 'localStorage');
if (!safe) return safe;
- safe = isFunctionCallSafe(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE);
+ safe = canCallFunction(window.localStorage, 'setItem', TEST_KEY, TEST_VALUE);
if (safe) window.localStorage.removeItem(TEST_KEY);
@@ -39,9 +51,7 @@ function isLocalStorageAccessSafe() {
}
const AccessorUtilities = {
- isPropertyAccessSafe,
- isFunctionCallSafe,
- isLocalStorageAccessSafe,
+ canUseLocalStorage,
};
export default AccessorUtilities;
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 8f86fd55d6e..fd9629499b0 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -117,7 +117,6 @@ export const handleLocationHash = () => {
};
// Check if element scrolled into viewport from above or below
-// Courtesy http://stackoverflow.com/a/7557433/414749
export const isInViewport = (el, offset = {}) => {
const rect = el.getBoundingClientRect();
const { top, left } = offset;
@@ -560,11 +559,9 @@ export const addSelectOnFocusBehaviour = (selector = '.js-select-on-focus') => {
* Method to round of values with decimal places
* with provided precision.
*
- * Taken from https://stackoverflow.com/a/7343013/414749
- *
* Eg; roundOffFloat(3.141592, 3) = 3.142
*
- * Refer to spec/javascripts/lib/utils/common_utils_spec.js for
+ * Refer to spec/frontend/lib/utils/common_utils_spec.js for
* more supported examples.
*
* @param {Float} number
@@ -581,7 +578,7 @@ export const roundOffFloat = (number, precision = 0) => {
*
* Eg; roundToNearestHalf(3.141592) = 3, roundToNearestHalf(3.41592) = 3.5
*
- * Refer to spec/javascripts/lib/utils/common_utils_spec.js for
+ * Refer to spec/frontend/lib/utils/common_utils_spec.js for
* more supported examples.
*
* @param {Float} number
@@ -595,7 +592,7 @@ export const roundToNearestHalf = (num) => Math.round(num * 2).toFixed() / 2;
*
* Eg; roundDownFloat(3.141592, 3) = 3.141
*
- * Refer to spec/javascripts/lib/utils/common_utils_spec.js for
+ * Refer to spec/frontend/lib/utils/common_utils_spec.js for
* more supported examples.
*
* @param {Float} number
@@ -645,7 +642,7 @@ export const NavigationType = {
* matched with our query.
*
* You can learn more about behaviour of this method by referring to tests
- * within `spec/javascripts/lib/utils/common_utils_spec.js`.
+ * within `spec/frontend/lib/utils/common_utils_spec.js`.
*
* @param {string} query String to search for
* @param {object} searchSpace Object containing properties to search in for `query`
diff --git a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
index 246f290a90a..0a35efb0ac8 100644
--- a/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime/date_format_utility.js
@@ -1,5 +1,5 @@
import dateFormat from 'dateformat';
-import { isString, mapValues, reduce } from 'lodash';
+import { isString, mapValues, reduce, isDate } from 'lodash';
import { s__, n__, __ } from '../../../locale';
/**
@@ -258,3 +258,106 @@ export const parseSeconds = (
return periodCount;
});
};
+
+/**
+ * Pads given items with zeros to reach a length of 2 characters.
+ *
+ * @param {...any} args Items to be padded.
+ * @returns {Array<String>} Padded items.
+ */
+export const padWithZeros = (...args) => args.map((arg) => `${arg}`.padStart(2, '0'));
+
+/**
+ * This removes the timezone from an ISO date string.
+ * This can be useful when populating date/time fields along with a distinct timezone selector, in
+ * which case we'd want to ignore the timezone's offset when populating the date and time.
+ *
+ * Examples:
+ * stripTimezoneFromISODate('2021-08-16T00:00:00.000-02:00') => '2021-08-16T00:00:00.000'
+ * stripTimezoneFromISODate('2021-08-16T00:00:00.000Z') => '2021-08-16T00:00:00.000'
+ *
+ * @param {String} date The ISO date string representation.
+ * @returns {String} The ISO date string without the timezone.
+ */
+export const stripTimezoneFromISODate = (date) => {
+ if (Number.isNaN(Date.parse(date))) {
+ return null;
+ }
+ return date.replace(/(Z|[+-]\d{2}:\d{2})$/, '');
+};
+
+/**
+ * Extracts the year, month and day from a Date instance and returns them in an object.
+ * For example:
+ * dateToYearMonthDate(new Date('2021-08-16')) => { year: '2021', month: '08', day: '16' }
+ *
+ * @param {Date} date The date to be parsed
+ * @returns {Object} An object containing the extracted year, month and day.
+ */
+export const dateToYearMonthDate = (date) => {
+ if (!isDate(date)) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Argument should be a Date instance');
+ }
+ const [month, day] = padWithZeros(date.getMonth() + 1, date.getDate());
+ return {
+ year: `${date.getFullYear()}`,
+ month,
+ day,
+ };
+};
+
+/**
+ * Extracts the hours and minutes from a string representing a time.
+ * For example:
+ * timeToHoursMinutes('12:46') => { hours: '12', minutes: '46' }
+ *
+ * @param {String} time The time to be parsed in the form HH:MM.
+ * @returns {Object} An object containing the hours and minutes.
+ */
+export const timeToHoursMinutes = (time = '') => {
+ if (!time || !time.match(/\d{1,2}:\d{1,2}/)) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Invalid time provided');
+ }
+ const [hours, minutes] = padWithZeros(...time.split(':'));
+ return { hours, minutes };
+};
+
+/**
+ * This combines a date and a time and returns the computed Date's ISO string representation.
+ *
+ * @param {Date} date Date object representing the base date.
+ * @param {String} time String representing the time to be used, in the form HH:MM.
+ * @param {String} offset An optional Date-compatible offset.
+ * @returns {String} The combined Date's ISO string representation.
+ */
+export const dateAndTimeToISOString = (date, time, offset = '') => {
+ const { year, month, day } = dateToYearMonthDate(date);
+ const { hours, minutes } = timeToHoursMinutes(time);
+ const dateString = `${year}-${month}-${day}T${hours}:${minutes}:00.000${offset || 'Z'}`;
+ if (Number.isNaN(Date.parse(dateString))) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Could not initialize date');
+ }
+ return dateString;
+};
+
+/**
+ * Converts a Date instance to time input-compatible value consisting in a 2-digits hours and
+ * minutes, separated by a semi-colon, in the 24-hours format.
+ *
+ * @param {Date} date Date to be converted
+ * @returns {String} time input-compatible string in the form HH:MM.
+ */
+export const dateToTimeInputValue = (date) => {
+ if (!isDate(date)) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Argument should be a Date instance');
+ }
+ return date.toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/dom_utils.js b/app/assets/javascripts/lib/utils/dom_utils.js
index f11c7658a88..f7687a929de 100644
--- a/app/assets/javascripts/lib/utils/dom_utils.js
+++ b/app/assets/javascripts/lib/utils/dom_utils.js
@@ -77,3 +77,15 @@ export const isElementVisible = (element) =>
* @returns {Boolean} `true` if the element is currently hidden, otherwise false
*/
export const isElementHidden = (element) => !isElementVisible(element);
+
+export const getParents = (element) => {
+ const parents = [];
+ let parent = element.parentNode;
+
+ do {
+ parents.push(parent);
+ parent = parent.parentNode;
+ } while (parent);
+
+ return parents;
+};
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index f3dedb7726a..f46263c0e4d 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -69,19 +69,20 @@ export function bytesToGiB(number) {
* representation (e.g., giving it 1500 yields 1.5 KB).
*
* @param {Number} size
+ * @param {Number} digits - The number of digits to appear after the decimal point
* @returns {String}
*/
-export function numberToHumanSize(size) {
+export function numberToHumanSize(size, digits = 2) {
const abs = Math.abs(size);
if (abs < BYTES_IN_KIB) {
return sprintf(__('%{size} bytes'), { size });
} else if (abs < BYTES_IN_KIB ** 2) {
- return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(2) });
+ return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(digits) });
} else if (abs < BYTES_IN_KIB ** 3) {
- return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(2) });
+ return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(digits) });
}
- return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(2) });
+ return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(digits) });
}
/**
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 6ff2af47dd8..0804d792631 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -232,7 +232,9 @@ export function insertMarkdownText({
.join('\n');
}
} else if (tag.indexOf(textPlaceholder) > -1) {
- textToInsert = tag.replace(textPlaceholder, () => selected.replace(/\\n/g, '\n'));
+ textToInsert = tag.replace(textPlaceholder, () =>
+ selected.replace(/\\n/g, '\n').replace('%br', '\\n'),
+ );
} else {
textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
}
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index e9772232eaf..bca0e45d98d 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -418,43 +418,6 @@ export const urlParamsToArray = (path = '') =>
export const getUrlParamsArray = () => urlParamsToArray(window.location.search);
/**
- * Accepts encoding string which includes query params being
- * sent to URL.
- *
- * @param {string} path Query param string
- *
- * @returns {object} Query params object containing key-value pairs
- * with both key and values decoded into plain string.
- *
- * @deprecated Please use `queryToObject(query, { gatherArrays: true });` instead. See https://gitlab.com/gitlab-org/gitlab/-/issues/328845
- */
-export const urlParamsToObject = (path = '') =>
- splitPath(path).reduce((dataParam, filterParam) => {
- if (filterParam === '') {
- return dataParam;
- }
-
- const data = dataParam;
- let [key, value] = filterParam.split('=');
- key = /%\w+/g.test(key) ? decodeURIComponent(key) : key;
- const isArray = key.includes('[]');
- key = key.replace('[]', '');
- value = decodeURIComponent(value.replace(/\+/g, ' '));
-
- if (isArray) {
- if (!data[key]) {
- data[key] = [];
- }
-
- data[key].push(value);
- } else {
- data[key] = value;
- }
-
- return data;
- }, {});
-
-/**
* Convert search query into an object
*
* @param {String} query from "document.location.search"
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 1aaefcaa13b..b96a2607552 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -19,6 +19,7 @@ import initAlertHandler from './alert_handler';
import { removeFlashClickListener } from './flash';
import initTodoToggle from './header';
import initLayoutNav from './layout_nav';
+import { logHelloDeferred } from './lib/logger/hello_deferred';
import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime/timeago_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
@@ -35,8 +36,12 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import initBroadcastNotifications from './broadcast_notification';
import { initTopNav } from './nav';
+import { initHeaderSearchApp } from '~/header_search';
import 'ee_else_ce/main_ee';
+import 'jh_else_ce/main_jh';
+
+logHelloDeferred();
applyGitLabUIConfig();
@@ -94,20 +99,24 @@ function deferredInitialisation() {
initDefaultTrackers();
initFeatureHighlight();
- const search = document.querySelector('#search');
- if (search) {
- search.addEventListener(
- 'focus',
- () => {
- import(/* webpackChunkName: 'globalSearch' */ './search_autocomplete')
- .then(({ default: initSearchAutocomplete }) => {
- const searchDropdown = initSearchAutocomplete();
- searchDropdown.onSearchInputFocus();
- })
- .catch(() => {});
- },
- { once: true },
- );
+ if (gon.features?.newHeaderSearch) {
+ initHeaderSearchApp();
+ } else {
+ const search = document.querySelector('#search');
+ if (search) {
+ search.addEventListener(
+ 'focus',
+ () => {
+ import(/* webpackChunkName: 'globalSearch' */ './search_autocomplete')
+ .then(({ default: initSearchAutocomplete }) => {
+ const searchDropdown = initSearchAutocomplete();
+ searchDropdown.onSearchInputFocus();
+ })
+ .catch(() => {});
+ },
+ { once: true },
+ );
+ }
}
addSelectOnFocusBehaviour('.js-select-on-focus');
diff --git a/app/assets/javascripts/main_jh.js b/app/assets/javascripts/main_jh.js
new file mode 100644
index 00000000000..13a6b8f3d3d
--- /dev/null
+++ b/app/assets/javascripts/main_jh.js
@@ -0,0 +1 @@
+// This is an empty file to satisfy jh_else_ce import for the JH main entry point
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 0ddb2c2334c..ed32f26583e 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -19,11 +19,9 @@ function MergeRequest(opts) {
this.opts = opts != null ? opts : {};
this.submitNoteForm = this.submitNoteForm.bind(this);
this.$el = $('.merge-request');
- this.$('.show-all-commits').on('click', () => this.showAllCommits());
this.initTabs();
this.initMRBtnListeners();
- this.initCommitMessageListeners();
if ($('.description.js-task-list-container').length) {
this.taskList = new TaskList({
@@ -59,11 +57,6 @@ MergeRequest.prototype.initTabs = function () {
window.mrTabs = new MergeRequestTabs(this.opts);
};
-MergeRequest.prototype.showAllCommits = function () {
- this.$('.first-commits').remove();
- return this.$('.all-commits').removeClass('hide');
-};
-
MergeRequest.prototype.initMRBtnListeners = function () {
const _this = this;
const draftToggles = document.querySelectorAll('.js-draft-toggle-button');
@@ -128,26 +121,6 @@ MergeRequest.prototype.submitNoteForm = function (form, $button) {
}
};
-MergeRequest.prototype.initCommitMessageListeners = function () {
- $(document).on('click', 'a.js-with-description-link', (e) => {
- const textarea = $('textarea.js-commit-message');
- e.preventDefault();
-
- textarea.val(textarea.data('messageWithDescription'));
- $('.js-with-description-hint').hide();
- $('.js-without-description-hint').show();
- });
-
- $(document).on('click', 'a.js-without-description-link', (e) => {
- const textarea = $('textarea.js-commit-message');
- e.preventDefault();
-
- textarea.val(textarea.data('messageWithoutDescription'));
- $('.js-with-description-hint').show();
- $('.js-without-description-hint').hide();
- });
-};
-
MergeRequest.decreaseCounter = function (by = 1) {
const $el = $('.js-merge-counter');
const count = Math.max(parseInt($el.text().replace(/[^\d]/, ''), 10) - by, 0);
@@ -164,7 +137,7 @@ MergeRequest.hideCloseButton = function () {
MergeRequest.toggleDraftStatus = function (title, isReady) {
if (isReady) {
createFlash({
- message: __('The merge request can now be merged.'),
+ message: __('Marked as ready. Merging is now allowed.'),
type: 'notice',
});
}
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 14e5e96d7b0..a40caea1223 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -7,20 +7,17 @@ import createEventHub from '~/helpers/event_hub_factory';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import Diff from './diff';
import createFlash from './flash';
-import initChangesDropdown from './init_changes_dropdown';
+import { initDiffStatsDropdown } from './init_diff_stats_dropdown';
import axios from './lib/utils/axios_utils';
import {
parseUrlPathname,
- handleLocationHash,
isMetaClick,
parseBoolean,
scrollToElement,
} from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
-import { getLocationHash } from './lib/utils/url_utility';
import { __ } from './locale';
-import Notes from './notes';
import syntaxHighlight from './syntax_highlight';
// MergeRequestTabs
@@ -67,6 +64,8 @@ import syntaxHighlight from './syntax_highlight';
// </div>
//
+// <100ms is typically indistinguishable from "instant" for users, but allows for re-rendering
+const FAST_DELAY_FOR_RERENDER = 75;
// Store the `location` object, allowing for easier stubbing in tests
let { location } = window;
@@ -86,6 +85,8 @@ export default class MergeRequestTabs {
this.peek = document.getElementById('js-peek');
this.paddingTop = 16;
+ this.scrollPositions = {};
+
this.commitsTab = document.querySelector('.tab-content .commits.tab-pane');
this.currentTab = null;
@@ -139,11 +140,30 @@ export default class MergeRequestTabs {
}
}
+ storeScroll() {
+ if (this.currentTab) {
+ this.scrollPositions[this.currentTab] = document.documentElement.scrollTop;
+ }
+ }
+ recallScroll(action) {
+ const storedPosition = this.scrollPositions[action];
+
+ setTimeout(() => {
+ window.scrollTo({
+ top: storedPosition && storedPosition > 0 ? storedPosition : 0,
+ left: 0,
+ behavior: 'auto',
+ });
+ }, FAST_DELAY_FOR_RERENDER);
+ }
+
clickTab(e) {
if (e.currentTarget) {
e.stopImmediatePropagation();
e.preventDefault();
+ this.storeScroll();
+
const { action } = e.currentTarget.dataset || {};
if (isMetaClick(e)) {
@@ -193,6 +213,14 @@ export default class MergeRequestTabs {
this.destroyPipelinesView();
} else if (this.isDiffAction(action)) {
if (!isInVueNoteablePage()) {
+ /*
+ for pages where we have not yet converted to the new vue
+ implementation we load the diff tab content the old way,
+ inserting html rendered by the backend.
+
+ in practice, this only occurs when comparing commits in
+ the new merge request form page.
+ */
this.loadDiff(href);
}
if (bp.getBreakpointSize() !== 'xl') {
@@ -205,8 +233,14 @@ export default class MergeRequestTabs {
this.resetViewContainer();
this.mountPipelinesView();
} else {
- this.mergeRequestTabPanes.querySelector('#notes').style.display = 'block';
- this.mergeRequestTabs.querySelector('.notes-tab').classList.add('active');
+ const notesTab = this.mergeRequestTabs.querySelector('.notes-tab');
+ const notesPane = this.mergeRequestTabPanes.querySelector('#notes');
+ if (notesPane) {
+ notesPane.style.display = 'block';
+ }
+ if (notesTab) {
+ notesTab.classList.add('active');
+ }
if (bp.getBreakpointSize() !== 'xs') {
this.expandView();
@@ -216,6 +250,8 @@ export default class MergeRequestTabs {
}
$('.detail-page-description').renderGFM();
+
+ this.recallScroll(action);
} else if (action === this.currentAction) {
// ContentTop is used to handle anything at the top of the page before the main content
const mainContentContainer = document.querySelector('.content-wrapper');
@@ -379,6 +415,7 @@ export default class MergeRequestTabs {
pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
}
+ // load the diff tab content from the backend
loadDiff(source) {
if (this.diffsLoaded) {
document.dispatchEvent(new CustomEvent('scroll'));
@@ -396,8 +433,7 @@ export default class MergeRequestTabs {
.then(({ data }) => {
const $container = $('#diffs');
$container.html(data.html);
-
- initChangesDropdown(this.stickyTop);
+ initDiffStatsDropdown(this.stickyTop);
localTimeAgo(document.querySelectorAll('#diffs .js-timeago'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
@@ -420,25 +456,6 @@ export default class MergeRequestTabs {
}).init();
});
- // Scroll any linked note into view
- // Similar to `toggler_behavior` in the discussion tab
- const hash = getLocationHash();
- const anchor = hash && $container.find(`.note[id="${hash}"]`);
- if (anchor && anchor.length > 0) {
- const notesContent = anchor.closest('.notes-content');
- const lineType = notesContent.hasClass('new') ? 'new' : 'old';
- Notes.instance.toggleDiffNote({
- target: anchor,
- lineType,
- forceShow: true,
- });
- anchor[0].scrollIntoView();
- handleLocationHash();
- // We have multiple elements on the page with `#note_xxx`
- // (discussion and diff tabs) and `:target` only applies to the first
- anchor.addClass('target');
- }
-
this.toggleLoading(false);
})
.catch(() => {
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 0d9a2eef01a..aa8a40b6a87 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -1,6 +1,5 @@
/* eslint-disable one-var, no-self-compare, consistent-return, no-param-reassign, no-shadow */
/* global Issuable */
-/* global ListMilestone */
import $ from 'jquery';
import { template, escape } from 'lodash';
@@ -8,10 +7,6 @@ import Api from '~/api';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { __, sprintf } from '~/locale';
import { sortMilestonesByDueDate } from '~/milestones/milestone_utils';
-import boardsStore, {
- boardStoreIssueSet,
- boardStoreIssueDelete,
-} from './boards/stores/boards_store';
import axios from './lib/utils/axios_utils';
import { timeFor, parsePikadayDate, dateInWords } from './lib/utils/datetime_utility';
@@ -186,18 +181,17 @@ export default class MilestoneSelect {
},
opened: (e) => {
const $el = $(e.currentTarget);
- if ($dropdown.hasClass('js-issue-board-sidebar') || options.handleClick) {
+ if (options.handleClick) {
selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
}
$('a.is-active', $el).removeClass('is-active');
$(`[data-milestone-id="${selectedMilestone}"] > a`, $el).addClass('is-active');
},
- vue: $dropdown.hasClass('js-issue-board-sidebar'),
+ vue: false,
clicked: (clickEvent) => {
const { e } = clickEvent;
let selected = clickEvent.selectedObj;
- let data;
if (!selected) return;
if (options.handleClick) {
@@ -224,76 +218,52 @@ export default class MilestoneSelect {
return Issuable.filterResults($dropdown.closest('form'));
} else if ($dropdown.hasClass('js-filter-submit')) {
return $dropdown.closest('form').submit();
- } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- if (selected.id !== -1 && isSelecting) {
- boardStoreIssueSet(
- 'milestone',
- new ListMilestone({
- id: selected.id,
- title: selected.name,
- }),
- );
- } else {
- boardStoreIssueDelete('milestone');
- }
+ }
- $dropdown.trigger('loading.gl.dropdown');
- $loading.removeClass('gl-display-none');
+ selected = $selectBox.find('input[type="hidden"]').val();
- boardsStore.detail.issue
- .update($dropdown.attr('data-issue-update'))
- .then(() => {
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.addClass('gl-display-none');
- })
- .catch(() => {
- $loading.addClass('gl-display-none');
- });
- } else {
- selected = $selectBox.find('input[type="hidden"]').val();
- data = {};
- data[abilityName] = {};
- data[abilityName].milestone_id = selected != null ? selected : null;
- $loading.removeClass('gl-display-none');
- $dropdown.trigger('loading.gl.dropdown');
- return axios
- .put(issueUpdateURL, data)
- .then(({ data }) => {
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.addClass('gl-display-none');
- $selectBox.hide();
- $value.css('display', '');
- if (data.milestone != null) {
- data.milestone.remaining = timeFor(data.milestone.due_date);
- data.milestone.name = data.milestone.title;
- $value.html(
- data.milestone.expired
- ? milestoneExpiredLinkTemplate({
- ...data.milestone,
- remaining: sprintf(__('%{due_date} (Past due)'), {
- due_date: dateInWords(parsePikadayDate(data.milestone.due_date)),
- }),
- })
- : milestoneLinkTemplate(data.milestone),
- );
- return $sidebarCollapsedValue
- .attr(
- 'data-original-title',
- `${data.milestone.name}<br />${data.milestone.remaining}`,
- )
- .find('span')
- .text(data.milestone.title);
- }
- $value.html(milestoneLinkNoneTemplate);
+ const data = {};
+ data[abilityName] = {};
+ data[abilityName].milestone_id = selected != null ? selected : null;
+ $loading.removeClass('gl-display-none');
+ $dropdown.trigger('loading.gl.dropdown');
+ return axios
+ .put(issueUpdateURL, data)
+ .then(({ data }) => {
+ $dropdown.trigger('loaded.gl.dropdown');
+ $loading.addClass('gl-display-none');
+ $selectBox.hide();
+ $value.css('display', '');
+ if (data.milestone != null) {
+ data.milestone.remaining = timeFor(data.milestone.due_date);
+ data.milestone.name = data.milestone.title;
+ $value.html(
+ data.milestone.expired
+ ? milestoneExpiredLinkTemplate({
+ ...data.milestone,
+ remaining: sprintf(__('%{due_date} (Past due)'), {
+ due_date: dateInWords(parsePikadayDate(data.milestone.due_date)),
+ }),
+ })
+ : milestoneLinkTemplate(data.milestone),
+ );
return $sidebarCollapsedValue
- .attr('data-original-title', __('Milestone'))
+ .attr(
+ 'data-original-title',
+ `${data.milestone.name}<br />${data.milestone.remaining}`,
+ )
.find('span')
- .text(__('None'));
- })
- .catch(() => {
- $loading.addClass('gl-display-none');
- });
- }
+ .text(data.milestone.title);
+ }
+ $value.html(milestoneLinkNoneTemplate);
+ return $sidebarCollapsedValue
+ .attr('data-original-title', __('Milestone'))
+ .find('span')
+ .text(__('None'));
+ })
+ .catch(() => {
+ $loading.addClass('gl-display-none');
+ });
},
});
});
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue
index e8499015210..a840e696386 100644
--- a/app/assets/javascripts/milestones/components/milestone_combobox.vue
+++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue
@@ -125,8 +125,7 @@ export default {
// This method is defined here instead of in `methods`
// because we need to access the .cancel() method
// lodash attaches to the function, which is
- // made inaccessible by Vue. More info:
- // https://stackoverflow.com/a/52988020/1063392
+ // made inaccessible by Vue.
this.debouncedSearch = debounce(function search() {
this.search(this.searchQuery);
}, SEARCH_DEBOUNCE_MS);
diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js
index 3a7babf6fa0..1f88c0a1ea6 100644
--- a/app/assets/javascripts/milestones/stores/mutations.js
+++ b/app/assets/javascripts/milestones/stores/mutations.js
@@ -38,7 +38,7 @@ export default {
[types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response) {
state.matches.projectMilestones = {
list: response.data.map(({ title }) => ({ title })),
- totalCount: parseInt(response.headers['x-total'], 10),
+ totalCount: parseInt(response.headers['x-total'], 10) || response.data.length,
error: null,
};
},
@@ -52,7 +52,7 @@ export default {
[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response) {
state.matches.groupMilestones = {
list: response.data.map(({ title }) => ({ title })),
- totalCount: parseInt(response.headers['x-total'], 10),
+ totalCount: parseInt(response.headers['x-total'], 10) || response.data.length,
error: null,
};
},
diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
index 446ca8e5090..4b54cffe231 100644
--- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
+++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg';
import { chartHeight } from '../../constants';
@@ -26,7 +25,7 @@ export default {
<div
class="gl-mt-3 svg-w-100 d-flex align-items-center"
:style="svgContainerStyle"
- v-html="chartEmptyStateIllustration"
+ v-html="chartEmptyStateIllustration /* eslint-disable-line vue/no-v-html */"
></div>
<h5 class="text-center gl-mt-3">{{ __('No data to display') }}</h5>
</div>
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index a7696a716d0..ea3e4e5604c 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -19,6 +19,7 @@ export default function initMrNotes() {
action: mrShowNode.dataset.mrAction,
});
+ initDiffsApp(store);
initNotesApp();
document.addEventListener('merged:UpdateActions', () => {
@@ -26,20 +27,25 @@ export default function initMrNotes() {
initCherryPickCommitModal();
});
- // eslint-disable-next-line no-new
- new Vue({
- el: '#js-vue-discussion-counter',
- name: 'DiscussionCounter',
- components: {
- discussionCounter,
- },
- store,
- render(createElement) {
- return createElement('discussion-counter');
- },
- });
+ requestIdleCallback(() => {
+ const el = document.getElementById('js-vue-discussion-counter');
- initDiscussionFilters(store);
- initSortDiscussions(store);
- initDiffsApp(store);
+ if (el) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ name: 'DiscussionCounter',
+ components: {
+ discussionCounter,
+ },
+ store,
+ render(createElement) {
+ return createElement('discussion-counter');
+ },
+ });
+ }
+
+ initDiscussionFilters(store);
+ initSortDiscussions(store);
+ });
}
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 0f4cec67ce8..1384c9c40b3 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import katex from 'katex';
import marked from 'marked';
import { sanitize } from '~/lib/dompurify';
@@ -95,7 +94,16 @@ renderer.image = function image(href, title, text) {
const attachmentHeader = `attachment:`; // eslint-disable-line @gitlab/require-i18n-strings
if (!this.attachments || !href.startsWith(attachmentHeader)) {
- return this.originalImage(href, title, text);
+ let relativeHref = href;
+
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ if (!(href.startsWith('http') || href.startsWith('data:'))) {
+ // These are images within the repo. This will only work if the image
+ // is relative to the path where the file is located
+ relativeHref = this.relativeRawPath + href;
+ }
+
+ return this.originalImage(relativeHref, title, text);
}
let img = ``;
@@ -130,6 +138,7 @@ export default {
components: {
prompt: Prompt,
},
+ inject: ['relativeRawPath'],
props: {
cell: {
type: Object,
@@ -139,6 +148,7 @@ export default {
computed: {
markdown() {
renderer.attachments = this.cell.attachments;
+ renderer.relativeRawPath = this.relativeRawPath;
return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), markdownConfig);
},
@@ -149,7 +159,7 @@ export default {
<template>
<div class="cell text-cell">
<prompt />
- <div class="markdown" v-html="markdown"></div>
+ <div class="markdown" v-html="markdown /* eslint-disable-line vue/no-v-html */"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index dc5b2b66348..ca02ee18dd1 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -1,6 +1,5 @@
<script>
import { GlSafeHtmlDirective } from '@gitlab/ui';
-import { sanitize } from '~/lib/dompurify';
import Prompt from '../prompt.vue';
export default {
@@ -25,19 +24,19 @@ export default {
},
},
computed: {
- sanitizedOutput() {
- return sanitize(this.rawCode);
- },
showOutput() {
return this.index === 0;
},
},
+ safeHtmlConfig: {
+ ADD_TAGS: ['use'], // to support icon SVGs
+ },
};
</script>
<template>
<div class="output">
<prompt type="Out" :count="count" :show-output="showOutput" />
- <div v-safe-html="sanitizedOutput" class="gl-overflow-auto"></div>
+ <div v-safe-html:[$options.safeHtmlConfig]="rawCode" class="gl-overflow-auto"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 2ebebd76e1e..4e31fdcd4f0 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,14 +1,5 @@
<script>
-import {
- GlAlert,
- GlButton,
- GlIcon,
- GlFormCheckbox,
- GlTooltipDirective,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
-} from '@gitlab/ui';
+import { GlAlert, GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
import Autosize from 'autosize';
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
@@ -34,6 +25,7 @@ import { COMMENT_FORM } from '../i18n';
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
+import CommentTypeDropdown from './comment_type_dropdown.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
@@ -42,8 +34,6 @@ const { UNPROCESSABLE_ENTITY } = httpStatusCodes;
export default {
name: 'CommentForm',
i18n: COMMENT_FORM,
- noteTypeComment: constants.COMMENT,
- noteTypeDiscussion: constants.DISCUSSION,
components: {
noteSignedOutWidget,
discussionLockedWidget,
@@ -53,10 +43,8 @@ export default {
TimelineEntryItem,
GlIcon,
CommentFieldLayout,
+ CommentTypeDropdown,
GlFormCheckbox,
- GlDropdown,
- GlDropdownItem,
- GlDropdownDivider,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -88,12 +76,6 @@ export default {
'hasDrafts',
]),
...mapState(['isToggleStateButtonLoading']),
- isNoteTypeComment() {
- return this.noteType === constants.COMMENT;
- },
- isNoteTypeDiscussion() {
- return this.noteType === constants.DISCUSSION;
- },
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
@@ -105,15 +87,8 @@ export default {
? this.$options.i18n.comment
: this.$options.i18n.startThread;
},
- startDiscussionDescription() {
- return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
- ? this.$options.i18n.discussionThatNeedsResolution
- : this.$options.i18n.discussion;
- },
- commentDescription() {
- return sprintf(this.$options.i18n.submitButton.commentHelp, {
- noteableDisplayName: this.noteableDisplayName,
- });
+ discussionsRequireResolution() {
+ return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE;
},
isOpen() {
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
@@ -314,15 +289,6 @@ export default {
this.autosave.reset();
},
- setNoteType(type) {
- this.noteType = type;
- },
- setNoteTypeToComment() {
- this.setNoteType(constants.COMMENT);
- },
- setNoteTypeToDiscussion() {
- this.setNoteType(constants.DISCUSSION);
- },
editCurrentUserLastNote() {
if (this.note === '') {
const lastNote = this.getCurrentUserLastNote;
@@ -448,40 +414,15 @@ export default {
class="gl-text-gray-500"
/>
</gl-form-checkbox>
- <gl-dropdown
- split
- :text="commentButtonTitle"
- class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
- category="primary"
- variant="confirm"
+ <comment-type-dropdown
+ v-model="noteType"
+ class="gl-mr-3"
:disabled="disableSubmitButton"
- data-testid="comment-button"
- data-qa-selector="comment_button"
- :data-track-label="trackingLabel"
- data-track-event="click_button"
- @click="handleSave()"
- >
- <gl-dropdown-item
- is-check-item
- :is-checked="isNoteTypeComment"
- :selected="isNoteTypeComment"
- @click="setNoteTypeToComment"
- >
- <strong>{{ $options.i18n.submitButton.comment }}</strong>
- <p class="gl-m-0">{{ commentDescription }}</p>
- </gl-dropdown-item>
- <gl-dropdown-divider />
- <gl-dropdown-item
- is-check-item
- :is-checked="isNoteTypeDiscussion"
- :selected="isNoteTypeDiscussion"
- data-qa-selector="discussion_menu_item"
- @click="setNoteTypeToDiscussion"
- >
- <strong>{{ $options.i18n.submitButton.startThread }}</strong>
- <p class="gl-m-0">{{ startDiscussionDescription }}</p>
- </gl-dropdown-item>
- </gl-dropdown>
+ :tracking-label="trackingLabel"
+ :noteable-display-name="noteableDisplayName"
+ :discussions-require-resolution="discussionsRequireResolution"
+ @click="handleSave"
+ />
</template>
<gl-button
v-if="canToggleIssueState"
diff --git a/app/assets/javascripts/notes/components/comment_type_dropdown.vue b/app/assets/javascripts/notes/components/comment_type_dropdown.vue
new file mode 100644
index 00000000000..663a912999d
--- /dev/null
+++ b/app/assets/javascripts/notes/components/comment_type_dropdown.vue
@@ -0,0 +1,114 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
+
+import { sprintf } from '~/locale';
+import { COMMENT_FORM } from '~/notes/i18n';
+import * as constants from '../constants';
+
+export default {
+ i18n: COMMENT_FORM,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ },
+ model: {
+ prop: 'noteType',
+ event: 'change',
+ },
+ props: {
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ trackingLabel: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ discussionsRequireResolution: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ noteableDisplayName: {
+ type: String,
+ required: true,
+ },
+ noteType: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isNoteTypeComment() {
+ return this.noteType === constants.COMMENT;
+ },
+ isNoteTypeDiscussion() {
+ return this.noteType === constants.DISCUSSION;
+ },
+ commentButtonTitle() {
+ return this.noteType === constants.COMMENT
+ ? this.$options.i18n.comment
+ : this.$options.i18n.startThread;
+ },
+ startDiscussionDescription() {
+ return this.discussionsRequireResolution
+ ? this.$options.i18n.discussionThatNeedsResolution
+ : this.$options.i18n.discussion;
+ },
+ commentDescription() {
+ return sprintf(this.$options.i18n.submitButton.commentHelp, {
+ noteableDisplayName: this.noteableDisplayName,
+ });
+ },
+ },
+ methods: {
+ handleClick() {
+ this.$emit('click');
+ },
+ setNoteTypeToComment() {
+ if (this.noteType !== constants.COMMENT) {
+ this.$emit('change', constants.COMMENT);
+ }
+ },
+ setNoteTypeToDiscussion() {
+ if (this.noteType !== constants.DISCUSSION) {
+ this.$emit('change', constants.DISCUSSION);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ split
+ :text="commentButtonTitle"
+ class="gl-mr-3 js-comment-button js-comment-submit-button comment-type-dropdown"
+ category="primary"
+ variant="confirm"
+ :disabled="disabled"
+ data-testid="comment-button"
+ data-qa-selector="comment_button"
+ :data-track-label="trackingLabel"
+ data-track-action="click_button"
+ @click="$emit('click')"
+ >
+ <gl-dropdown-item is-check-item :is-checked="isNoteTypeComment" @click="setNoteTypeToComment">
+ <strong>{{ $options.i18n.submitButton.comment }}</strong>
+ <p class="gl-m-0">{{ commentDescription }}</p>
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item
+ is-check-item
+ :is-checked="isNoteTypeDiscussion"
+ data-qa-selector="discussion_menu_item"
+ @click="setNoteTypeToDiscussion"
+ >
+ <strong>{{ $options.i18n.submitButton.startThread }}</strong>
+ <p class="gl-m-0">{{ startDiscussionDescription }}</p>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index e96e1204f76..b04aa74d46e 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
@@ -93,7 +92,11 @@ export default {
>
<td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td>
<td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td>
- <td :class="line.type" class="line_content" v-html="trimChar(line.rich_text)"></td>
+ <td
+ :class="line.type"
+ class="line_content"
+ v-html="trimChar(line.rich_text) /* eslint-disable-line vue/no-v-html */"
+ ></td>
</tr>
</template>
<tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder">
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 55cf75132a9..831e6dd8f92 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -78,8 +78,8 @@ export default {
v-if="resolveAllDiscussionsIssuePath && !allResolved"
v-gl-tooltip
:href="resolveAllDiscussionsIssuePath"
- :title="s__('Resolve all threads in new issue')"
- :aria-label="s__('Resolve all threads in new issue')"
+ :title="s__('Create issue to resolve all threads')"
+ :aria-label="s__('Create issue to resolve all threads')"
class="new-issue-for-discussion discussion-create-issue-btn"
icon="issue-new"
/>
@@ -89,7 +89,7 @@ export default {
:title="__('Jump to next unresolved thread')"
:aria-label="__('Jump to next unresolved thread')"
class="discussion-next-btn"
- data-track-event="click_button"
+ data-track-action="click_button"
data-track-label="mr_next_unresolved_thread"
data-track-property="click_next_unresolved_thread_top"
icon="comment-next"
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
index 9119d319d72..4ccba011014 100644
--- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
+++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
@@ -4,7 +4,7 @@ import { s__ } from '~/locale';
export default {
i18n: {
- buttonLabel: s__('MergeRequests|Resolve this thread in a new issue'),
+ buttonLabel: s__('MergeRequests|Create issue to resolve thread'),
},
name: 'ResolveWithIssueButton',
components: {
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
index 0cd2afcf8a0..8c8cc7984b1 100644
--- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -19,7 +19,7 @@ export default {
<template>
<gl-button
v-gl-tooltip
- data-track-event="click_button"
+ data-track-action="click_button"
data-track-label="reply_comment_button"
category="tertiary"
icon="comment"
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 9864e91c009..93f71276120 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import $ from 'jquery';
import { escape } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
@@ -163,7 +162,11 @@ export default {
@addToBatch="addSuggestionToBatch"
@removeFromBatch="removeSuggestionFromBatch"
/>
- <div v-else class="note-text md" v-html="note.note_html"></div>
+ <div
+ v-else
+ class="note-text md"
+ v-html="note.note_html /* eslint-disable-line vue/no-v-html */"
+ ></div>
<note-form
v-if="isEditing"
ref="noteForm"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index f2336e1b6f5..a4f06a8d9f5 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlButton } from '@gitlab/ui';
import { mapGetters, mapActions, mapState } from 'vuex';
import { getDraft, updateDraft } from '~/lib/utils/autosave';
@@ -322,7 +321,7 @@ export default {
<div
v-if="conflictWhileEditing"
class="js-conflict-edit-warning alert alert-danger"
- v-html="changedCommentText"
+ v-html="changedCommentText /* eslint-disable-line vue/no-v-html */"
></div>
<div class="flash-container timeline-content"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 1a4a6c137a6..4e686ce8719 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapActions } from 'vuex';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -169,7 +168,7 @@ export default {
v-on="
authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {}
"
- v-html="authorStatus"
+ v-html="authorStatus /* eslint-disable-line vue/no-v-html */"
></span>
<span class="text-nowrap author-username">
<a
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 6a4a3263e4a..656591e0c32 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -1,3 +1,4 @@
+/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import $ from 'jquery';
import Visibility from 'visibilityjs';
import Vue from 'vue';
diff --git a/app/assets/javascripts/packages/details/components/package_history.vue b/app/assets/javascripts/packages/details/components/package_history.vue
index 0d7a73c12f1..27d2f208a42 100644
--- a/app/assets/javascripts/packages/details/components/package_history.vue
+++ b/app/assets/javascripts/packages/details/components/package_history.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { GlLink, GlSprintf } from '@gitlab/ui';
import { first } from 'lodash';
import { truncateSha } from '~/lib/utils/text_utility';
diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js
index b4cdca34d92..f15c31b85c1 100644
--- a/app/assets/javascripts/packages/shared/constants.js
+++ b/app/assets/javascripts/packages/shared/constants.js
@@ -1,3 +1,4 @@
+/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { __, s__ } from '~/locale';
export const PackageType = {
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue
index 4d6a1d5462b..74c0cb44c51 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue
@@ -1,25 +1,24 @@
<script>
-import { GlLink, GlSprintf } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import Composer from '~/packages_and_registries/package_registry/components/details/metadata/composer.vue';
+import Conan from '~/packages_and_registries/package_registry/components/details/metadata/conan.vue';
+import Maven from '~/packages_and_registries/package_registry/components/details/metadata/maven.vue';
+import Nuget from '~/packages_and_registries/package_registry/components/details/metadata/nuget.vue';
+import Pypi from '~/packages_and_registries/package_registry/components/details/metadata/pypi.vue';
import {
- PACKAGE_TYPE_NUGET,
+ PACKAGE_TYPE_COMPOSER,
PACKAGE_TYPE_CONAN,
PACKAGE_TYPE_MAVEN,
+ PACKAGE_TYPE_NUGET,
+ PACKAGE_TYPE_PYPI,
} from '~/packages_and_registries/package_registry/constants';
-import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
export default {
- i18n: {
- sourceText: s__('PackageRegistry|Source project located at %{link}'),
- licenseText: s__('PackageRegistry|License information located at %{link}'),
- recipeText: s__('PackageRegistry|Recipe: %{recipe}'),
- appGroup: s__('PackageRegistry|App group: %{group}'),
- appName: s__('PackageRegistry|App name: %{name}'),
- },
components: {
- DetailsRow,
- GlLink,
- GlSprintf,
+ Composer,
+ Conan,
+ Maven,
+ Nuget,
+ Pypi,
},
props: {
packageEntity: {
@@ -28,21 +27,17 @@ export default {
},
},
computed: {
- showMetadata() {
- return (
- [PACKAGE_TYPE_NUGET, PACKAGE_TYPE_CONAN, PACKAGE_TYPE_MAVEN].includes(
- this.packageEntity.packageType,
- ) && this.packageEntity.metadata
- );
- },
- showNugetMetadata() {
- return this.packageEntity.packageType === PACKAGE_TYPE_NUGET;
+ metadataComponent() {
+ return {
+ [PACKAGE_TYPE_COMPOSER]: Composer,
+ [PACKAGE_TYPE_CONAN]: Conan,
+ [PACKAGE_TYPE_MAVEN]: Maven,
+ [PACKAGE_TYPE_NUGET]: Nuget,
+ [PACKAGE_TYPE_PYPI]: Pypi,
+ }[this.packageEntity.packageType];
},
- showConanMetadata() {
- return this.packageEntity.packageType === PACKAGE_TYPE_CONAN;
- },
- showMavenMetadata() {
- return this.packageEntity.packageType === PACKAGE_TYPE_MAVEN;
+ showMetadata() {
+ return this.metadataComponent && this.packageEntity.metadata;
},
},
};
@@ -51,56 +46,12 @@ export default {
<template>
<div v-if="showMetadata">
<h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3>
-
<div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main">
- <template v-if="showNugetMetadata">
- <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source">
- <gl-sprintf :message="$options.i18n.sourceText">
- <template #link>
- <gl-link :href="packageEntity.metadata.projectUrl" target="_blank">{{
- packageEntity.metadata.projectUrl
- }}</gl-link>
- </template>
- </gl-sprintf>
- </details-row>
- <details-row icon="license" padding="gl-p-4" data-testid="nuget-license">
- <gl-sprintf :message="$options.i18n.licenseText">
- <template #link>
- <gl-link :href="packageEntity.metadata.licenseUrl" target="_blank">{{
- packageEntity.metadata.licenseUrl
- }}</gl-link>
- </template>
- </gl-sprintf>
- </details-row>
- </template>
-
- <details-row
- v-else-if="showConanMetadata"
- icon="information-o"
- padding="gl-p-4"
- data-testid="conan-recipe"
- >
- <gl-sprintf :message="$options.i18n.recipeText">
- <template #recipe>{{ packageEntity.metadata.recipe }}</template>
- </gl-sprintf>
- </details-row>
-
- <template v-else-if="showMavenMetadata">
- <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app">
- <gl-sprintf :message="$options.i18n.appName">
- <template #name>
- <strong>{{ packageEntity.metadata.appName }}</strong>
- </template>
- </gl-sprintf>
- </details-row>
- <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group">
- <gl-sprintf :message="$options.i18n.appGroup">
- <template #group>
- <strong>{{ packageEntity.metadata.appGroup }}</strong>
- </template>
- </gl-sprintf>
- </details-row>
- </template>
+ <component
+ :is="metadataComponent"
+ :package-entity="packageEntity"
+ data-testid="component-is"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue
new file mode 100644
index 00000000000..b6a36a0b00f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/composer.vue
@@ -0,0 +1,55 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+export default {
+ i18n: {
+ targetShaCopyButton: s__('PackageRegistry|Copy target SHA'),
+ targetSha: s__('PackageRegistry|Target SHA: %{sha}'),
+ composerJson: s__(
+ 'PackageRegistry|Composer.json with license: %{license} and version: %{version}',
+ ),
+ },
+ components: {
+ DetailsRow,
+ GlSprintf,
+ ClipboardButton,
+ },
+ props: {
+ packageEntity: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <details-row icon="information-o" padding="gl-p-4" dashed data-testid="composer-target-sha">
+ <gl-sprintf :message="$options.i18n.targetSha">
+ <template #sha>
+ <strong>{{ packageEntity.metadata.targetSha }}</strong>
+ <clipboard-button
+ :title="$options.i18n.targetShaCopyButton"
+ :text="packageEntity.metadata.targetSha"
+ category="tertiary"
+ css-class="gl-p-0!"
+ />
+ </template>
+ </gl-sprintf>
+ </details-row>
+ <details-row icon="information-o" padding="gl-p-4" data-testid="composer-json">
+ <gl-sprintf :message="$options.i18n.composerJson">
+ <template #license>
+ <strong>{{ packageEntity.metadata.composerJson.license }}</strong>
+ </template>
+ <template #version>
+ <strong>{{ packageEntity.metadata.composerJson.version }}</strong>
+ </template>
+ </gl-sprintf>
+ </details-row>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue
new file mode 100644
index 00000000000..10797d74acf
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/conan.vue
@@ -0,0 +1,32 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+export default {
+ i18n: {
+ recipeText: s__('PackageRegistry|Recipe: %{recipe}'),
+ },
+ components: {
+ DetailsRow,
+ GlSprintf,
+ },
+ props: {
+ packageEntity: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <details-row icon="information-o" padding="gl-p-4" data-testid="conan-recipe">
+ <gl-sprintf :message="$options.i18n.recipeText">
+ <template #recipe>{{ packageEntity.metadata.recipe }}</template>
+ </gl-sprintf>
+ </details-row>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue
new file mode 100644
index 00000000000..fd9fb49a9f2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/maven.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+export default {
+ i18n: {
+ appGroup: s__('PackageRegistry|App group: %{group}'),
+ appName: s__('PackageRegistry|App name: %{name}'),
+ },
+ components: {
+ DetailsRow,
+ GlSprintf,
+ },
+ props: {
+ packageEntity: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app">
+ <gl-sprintf :message="$options.i18n.appName">
+ <template #name>
+ <strong>{{ packageEntity.metadata.appName }}</strong>
+ </template>
+ </gl-sprintf>
+ </details-row>
+ <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group">
+ <gl-sprintf :message="$options.i18n.appGroup">
+ <template #group>
+ <strong>{{ packageEntity.metadata.appGroup }}</strong>
+ </template>
+ </gl-sprintf>
+ </details-row>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
new file mode 100644
index 00000000000..f0da7db6c91
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/nuget.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlLink, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+export default {
+ i18n: {
+ sourceText: s__('PackageRegistry|Source project located at %{link}'),
+ licenseText: s__('PackageRegistry|License information located at %{link}'),
+ },
+ components: {
+ DetailsRow,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ packageEntity: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source">
+ <gl-sprintf :message="$options.i18n.sourceText">
+ <template #link>
+ <gl-link :href="packageEntity.metadata.projectUrl" target="_blank">{{
+ packageEntity.metadata.projectUrl
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </details-row>
+ <details-row icon="license" padding="gl-p-4" data-testid="nuget-license">
+ <gl-sprintf :message="$options.i18n.licenseText">
+ <template #link>
+ <gl-link :href="packageEntity.metadata.licenseUrl" target="_blank">{{
+ packageEntity.metadata.licenseUrl
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </details-row>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue
new file mode 100644
index 00000000000..6534eef532c
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/metadata/pypi.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+
+export default {
+ i18n: {
+ requiredPython: s__('PackageRegistry|Required Python: %{pythonVersion}'),
+ },
+ components: {
+ DetailsRow,
+ GlSprintf,
+ },
+ props: {
+ packageEntity: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <details-row icon="information-o" padding="gl-p-4" data-testid="pypi-required-python">
+ <gl-sprintf :message="$options.i18n.requiredPython">
+ <template #pythonVersion>
+ <strong>{{ packageEntity.metadata.requiredPython }}</strong>
+ </template>
+ </gl-sprintf>
+ </details-row>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
index af4a984add4..408bd2e3dfe 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { GlLink, GlSprintf } from '@gitlab/ui';
import { first } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
index 65547af3913..44d7807639d 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlSprintf, GlBadge } from '@gitlab/ui';
+import { GlIcon, GlSprintf, GlBadge, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { __ } from '~/locale';
@@ -21,6 +21,9 @@ export default {
GlBadge,
TimeAgoTooltip,
},
+ directives: {
+ GlResizeObserver: GlResizeObserverDirective,
+ },
i18n: {
packageInfo: __('v%{version} published %{timeAgo}'),
},
@@ -60,18 +63,26 @@ export default {
},
},
mounted() {
- this.isDesktop = GlBreakpointInstance.isDesktop();
+ this.checkBreakpoints();
},
methods: {
dynamicSlotName(index) {
return `metadata-tag${index}`;
},
+ checkBreakpoints() {
+ this.isDesktop = GlBreakpointInstance.isDesktop();
+ },
},
};
</script>
<template>
- <title-area :title="packageEntity.name" :avatar="packageIcon" data-qa-selector="package_title">
+ <title-area
+ v-gl-resize-observer="checkBreakpoints"
+ :title="packageEntity.name"
+ :avatar="packageIcon"
+ data-qa-selector="package_title"
+ >
<template #sub-header>
<gl-icon name="eye" class="gl-mr-3" />
<span data-testid="sub-header">
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
new file mode 100644
index 00000000000..280d292ce0b
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
@@ -0,0 +1,57 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { s__ } from '~/locale';
+import { sortableFields } from '~/packages/list/utils';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import UrlSync from '~/vue_shared/components/url_sync.vue';
+import PackageTypeToken from './tokens/package_type_token.vue';
+
+export default {
+ tokens: [
+ {
+ type: 'type',
+ icon: 'package',
+ title: s__('PackageRegistry|Type'),
+ unique: true,
+ token: PackageTypeToken,
+ operators: OPERATOR_IS_ONLY,
+ },
+ ],
+ components: { RegistrySearch, UrlSync },
+ computed: {
+ ...mapState({
+ isGroupPage: (state) => state.config.isGroupPage,
+ sorting: (state) => state.sorting,
+ filter: (state) => state.filter,
+ }),
+ sortableFields() {
+ return sortableFields(this.isGroupPage);
+ },
+ },
+ methods: {
+ ...mapActions(['setSorting', 'setFilter']),
+ updateSorting(newValue) {
+ this.setSorting(newValue);
+ this.$emit('update');
+ },
+ },
+};
+</script>
+
+<template>
+ <url-sync>
+ <template #default="{ updateQuery }">
+ <registry-search
+ :filter="filter"
+ :sorting="sorting"
+ :tokens="$options.tokens"
+ :sortable-fields="sortableFields"
+ @sorting:changed="updateSorting"
+ @filter:changed="setFilter"
+ @filter:submit="$emit('update')"
+ @query:changed="updateQuery"
+ />
+ </template>
+ </url-sync>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
new file mode 100644
index 00000000000..6e00a48586e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
@@ -0,0 +1,47 @@
+<script>
+import { n__ } from '~/locale';
+import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+export default {
+ name: 'PackageTitle',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ helpUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ showPackageCount() {
+ return Number.isInteger(this.count);
+ },
+ packageAmountText() {
+ return n__(`%d Package`, `%d Packages`, this.count);
+ },
+ infoMessages() {
+ return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }];
+ },
+ },
+ i18n: {
+ LIST_TITLE_TEXT,
+ },
+};
+</script>
+
+<template>
+ <title-area :title="$options.i18n.LIST_TITLE_TEXT" :info-messages="infoMessages">
+ <template #metadata-amount>
+ <metadata-item v-if="showPackageCount" icon="package" :text="packageAmountText" />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
new file mode 100644
index 00000000000..25bac687dbf
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -0,0 +1,129 @@
+<script>
+import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import { s__ } from '~/locale';
+import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
+import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
+import { TrackingActions } from '~/packages/shared/constants';
+import { packageTypeToTrackCategory } from '~/packages/shared/utils';
+import Tracking from '~/tracking';
+
+export default {
+ components: {
+ GlPagination,
+ GlModal,
+ GlSprintf,
+ PackagesListLoader,
+ PackagesListRow,
+ },
+ mixins: [Tracking.mixin()],
+ data() {
+ return {
+ itemToBeDeleted: null,
+ };
+ },
+ computed: {
+ ...mapState({
+ perPage: (state) => state.pagination.perPage,
+ totalItems: (state) => state.pagination.total,
+ page: (state) => state.pagination.page,
+ isGroupPage: (state) => state.config.isGroupPage,
+ isLoading: 'isLoading',
+ }),
+ ...mapGetters({ list: 'getList' }),
+ currentPage: {
+ get() {
+ return this.page;
+ },
+ set(value) {
+ this.$emit('page:changed', value);
+ },
+ },
+ isListEmpty() {
+ return !this.list || this.list.length === 0;
+ },
+ modalAction() {
+ return s__('PackageRegistry|Delete package');
+ },
+ deletePackageName() {
+ return this.itemToBeDeleted?.name ?? '';
+ },
+ tracking() {
+ const category = this.itemToBeDeleted
+ ? packageTypeToTrackCategory(this.itemToBeDeleted.package_type)
+ : undefined;
+ return {
+ category,
+ };
+ },
+ },
+ methods: {
+ setItemToBeDeleted(item) {
+ this.itemToBeDeleted = { ...item };
+ this.track(TrackingActions.REQUEST_DELETE_PACKAGE);
+ this.$refs.packageListDeleteModal.show();
+ },
+ deleteItemConfirmation() {
+ this.$emit('package:delete', this.itemToBeDeleted);
+ this.track(TrackingActions.DELETE_PACKAGE);
+ this.itemToBeDeleted = null;
+ },
+ deleteItemCanceled() {
+ this.track(TrackingActions.CANCEL_DELETE_PACKAGE);
+ this.itemToBeDeleted = null;
+ },
+ },
+ i18n: {
+ deleteModalContent: s__(
+ 'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?',
+ ),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <slot v-if="isListEmpty && !isLoading" name="empty-state"></slot>
+
+ <div v-else-if="isLoading">
+ <packages-list-loader />
+ </div>
+
+ <template v-else>
+ <div data-qa-selector="packages-table">
+ <packages-list-row
+ v-for="packageEntity in list"
+ :key="packageEntity.id"
+ :package-entity="packageEntity"
+ :package-link="packageEntity._links.web_path"
+ :is-group="isGroupPage"
+ @packageToDelete="setItemToBeDeleted"
+ />
+ </div>
+
+ <gl-pagination
+ v-model="currentPage"
+ :per-page="perPage"
+ :total-items="totalItems"
+ align="center"
+ class="gl-w-full gl-mt-3"
+ />
+
+ <gl-modal
+ ref="packageListDeleteModal"
+ modal-id="confirm-delete-pacakge"
+ ok-variant="danger"
+ @ok="deleteItemConfirmation"
+ @cancel="deleteItemCanceled"
+ >
+ <template #modal-title>{{ modalAction }}</template>
+ <template #modal-ok>{{ modalAction }}</template>
+ <gl-sprintf :message="$options.i18n.deleteModalContent">
+ <template #name>
+ <strong>{{ deletePackageName }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue
new file mode 100644
index 00000000000..75fbdb80192
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list_app.vue
@@ -0,0 +1,132 @@
+<script>
+import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import createFlash from '~/flash';
+import { historyReplaceState } from '~/lib/utils/common_utils';
+import { s__ } from '~/locale';
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
+import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
+import PackageList from './packages_list.vue';
+
+export default {
+ components: {
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
+ PackageList,
+ PackageTitle: () =>
+ import(/* webpackChunkName: 'package_registry_components' */ './package_title.vue'),
+ PackageSearch: () =>
+ import(/* webpackChunkName: 'package_registry_components' */ './package_search.vue'),
+ InfrastructureTitle: () =>
+ import(
+ /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_title.vue'
+ ),
+ InfrastructureSearch: () =>
+ import(
+ /* webpackChunkName: 'infrastructure_registry_components' */ '~/packages_and_registries/infrastructure_registry/components/infrastructure_search.vue'
+ ),
+ },
+ inject: {
+ titleComponent: {
+ from: 'titleComponent',
+ default: 'PackageTitle',
+ },
+ searchComponent: {
+ from: 'searchComponent',
+ default: 'PackageSearch',
+ },
+ emptyPageTitle: {
+ from: 'emptyPageTitle',
+ default: s__('PackageRegistry|There are no packages yet'),
+ },
+ noResultsText: {
+ from: 'noResultsText',
+ default: s__(
+ 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
+ ),
+ },
+ },
+ computed: {
+ ...mapState({
+ emptyListIllustration: (state) => state.config.emptyListIllustration,
+ emptyListHelpUrl: (state) => state.config.emptyListHelpUrl,
+ filter: (state) => state.filter,
+ selectedType: (state) => state.selectedType,
+ packageHelpUrl: (state) => state.config.packageHelpUrl,
+ packagesCount: (state) => state.pagination?.total,
+ }),
+ emptySearch() {
+ return (
+ this.filter.filter((f) => f.type !== FILTERED_SEARCH_TERM || f.value?.data).length === 0
+ );
+ },
+
+ emptyStateTitle() {
+ return this.emptySearch
+ ? this.emptyPageTitle
+ : s__('PackageRegistry|Sorry, your filter produced no results');
+ },
+ },
+ mounted() {
+ const queryParams = getQueryParams(window.document.location.search);
+ const { sorting, filters } = extractFilterAndSorting(queryParams);
+ this.setSorting(sorting);
+ this.setFilter(filters);
+ this.requestPackagesList();
+ this.checkDeleteAlert();
+ },
+ methods: {
+ ...mapActions([
+ 'requestPackagesList',
+ 'requestDeletePackage',
+ 'setSelectedType',
+ 'setSorting',
+ 'setFilter',
+ ]),
+ onPageChanged(page) {
+ return this.requestPackagesList({ page });
+ },
+ onPackageDeleteRequest(item) {
+ return this.requestDeletePackage(item);
+ },
+ checkDeleteAlert() {
+ const urlParams = new URLSearchParams(window.location.search);
+ const showAlert = urlParams.get(SHOW_DELETE_SUCCESS_ALERT);
+ if (showAlert) {
+ // to be refactored to use gl-alert
+ createFlash({ message: DELETE_PACKAGE_SUCCESS_MESSAGE, type: 'notice' });
+ const cleanUrl = window.location.href.split('?')[0];
+ historyReplaceState(cleanUrl);
+ }
+ },
+ },
+ i18n: {
+ widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <component :is="titleComponent" :help-url="packageHelpUrl" :count="packagesCount" />
+ <component :is="searchComponent" @update="requestPackagesList" />
+
+ <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
+ <template #empty-state>
+ <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
+ <template #description>
+ <gl-sprintf v-if="!emptySearch" :message="$options.i18n.widenFilters" />
+ <gl-sprintf v-else :message="noResultsText">
+ <template #noPackagesLink="{ content }">
+ <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </template>
+ </gl-empty-state>
+ </template>
+ </package-list>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue
new file mode 100644
index 00000000000..529a7893dfc
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue
@@ -0,0 +1,26 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
+import { PACKAGE_TYPES } from '~/packages/list/constants';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ },
+ PACKAGE_TYPES,
+};
+</script>
+
+<template>
+ <gl-filtered-search-token v-bind="{ ...$attrs }" v-on="$listeners">
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="(type, index) in $options.PACKAGE_TYPES"
+ :key="index"
+ :value="type.type"
+ >
+ {{ type.title }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index aad888b4433..f023b4481a0 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -1,3 +1,4 @@
+/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { __, s__ } from '~/locale';
export const PACKAGE_TYPE_CONAN = 'CONAN';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
new file mode 100644
index 00000000000..1e01b75aabc
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import PackagesListApp from '../components/list/packages_list_app.vue';
+
+Vue.use(Translate);
+
+export default () => {
+ const el = document.getElementById('js-vue-packages-list');
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(PackagesListApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
index 6da2e3a47e8..bf286c84d5f 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/project/components/registry_settings_app.vue
@@ -88,7 +88,7 @@ export default {
<template>
<section data-testid="registry-settings-app">
<cleanup-policy-enabled-alert v-if="showCleanupPolicyOnAlert" :project-path="projectPath" />
- <settings-block default-expanded>
+ <settings-block :collapsible="false">
<template #title> {{ __('Clean up image tags') }}</template>
<template #description>
<span data-testid="description">
diff --git a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue b/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue
index d17c37e9e1a..99461475af0 100644
--- a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue
+++ b/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue
@@ -77,7 +77,7 @@ export default {
);
if (button) {
- button.setAttribute('data-track-event', 'click_go_to_preferences');
+ button.setAttribute('data-track-action', 'click_go_to_preferences');
button.setAttribute('data-track-label', this.trackLabel);
}
},
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 342c054471d..8c9f23732aa 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,26 +1,30 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import issuableInitBulkUpdateSidebar from '~/issuable_bulk_update_sidebar/issuable_init_bulk_update_sidebar';
-import { mountIssuablesListApp } from '~/issues_list';
+import { mountIssuablesListApp, mountIssuesListApp } from '~/issues_list';
import initManualOrdering from '~/manual_ordering';
import { FILTERED_SEARCH } from '~/pages/constants';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import projectSelect from '~/project_select';
-const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
+if (gon.features?.vueIssuesList) {
+ mountIssuesListApp();
+} else {
+ const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
-IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
-IssuableFilteredSearchTokenKeys.removeTokensForKeys('release');
-issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
+ IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
+ IssuableFilteredSearchTokenKeys.removeTokensForKeys('release');
+ issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
-initFilteredSearch({
- page: FILTERED_SEARCH.ISSUES,
- isGroupDecendent: true,
- useDefaultState: true,
- filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
-});
-projectSelect();
-initManualOrdering();
+ initFilteredSearch({
+ page: FILTERED_SEARCH.ISSUES,
+ isGroupDecendent: true,
+ useDefaultState: true,
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
+ });
+ projectSelect();
+ initManualOrdering();
-if (gon.features?.vueIssuablesList) {
- mountIssuablesListApp();
+ if (gon.features?.vueIssuablesList) {
+ mountIssuablesListApp();
+ }
}
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 7557edb1b49..7b0418e1ad5 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -5,6 +5,7 @@ import Group from '~/group';
import { parseBoolean } from '~/lib/utils/common_utils';
import NewGroupCreationApp from './components/app.vue';
import GroupPathValidator from './group_path_validator';
+import initToggleInviteMembers from './toggle_invite_members';
new GroupPathValidator(); // eslint-disable-line no-new
@@ -31,3 +32,5 @@ function initNewGroupCreation(el) {
const el = document.querySelector('.js-new-group-creation');
initNewGroupCreation(el);
+
+initToggleInviteMembers();
diff --git a/app/assets/javascripts/pages/groups/new/toggle_invite_members.js b/app/assets/javascripts/pages/groups/new/toggle_invite_members.js
new file mode 100644
index 00000000000..ffb4964cf7d
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/new/toggle_invite_members.js
@@ -0,0 +1,14 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default function initToggleInviteMembers() {
+ const inviteMembersSection = document.querySelector('.js-invite-members-section');
+ const setupForCompanyRadios = document.querySelectorAll('input[name="group[setup_for_company]"]');
+
+ if (inviteMembersSection && setupForCompanyRadios.length) {
+ setupForCompanyRadios.forEach((el) => {
+ el.addEventListener('change', (event) => {
+ inviteMembersSection.classList.toggle('hidden', !parseBoolean(event.target.value));
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index b365e039191..80bcbefab46 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -14,7 +14,7 @@ import '~/sourcegraph/load';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
const viewBlobEl = document.querySelector('#js-view-blob-app');
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index e3b30560fef..c6a76df7bde 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -4,8 +4,8 @@ import loadAwardsHandler from '~/awards_handler';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import Diff from '~/diff';
import createFlash from '~/flash';
-import initChangesDropdown from '~/init_changes_dropdown';
-import initNotes from '~/init_notes';
+import initDeprecatedNotes from '~/init_deprecated_notes';
+import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
import axios from '~/lib/utils/axios_utils';
import { handleLocationHash } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
@@ -17,13 +17,13 @@ import '~/sourcegraph/load';
const hasPerfBar = document.querySelector('.with-performance-bar');
const performanceHeight = hasPerfBar ? 35 : 0;
-initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
+initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight);
new ZenMode();
new ShortcutsNavigation();
initCommitBoxInfo();
-initNotes();
+initDeprecatedNotes();
const filesContainer = $('.js-diffs-batch');
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
index 5edaa7f7e51..b74f7d1cf57 100644
--- a/app/assets/javascripts/pages/projects/compare/show/index.js
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -1,11 +1,11 @@
import Diff from '~/diff';
import GpgBadges from '~/gpg_badges';
-import initChangesDropdown from '~/init_changes_dropdown';
+import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
import initCompareSelector from '~/projects/compare';
initCompareSelector();
new Diff(); // eslint-disable-line no-new
const paddingTop = 16;
-initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
+initDiffStatsDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
GpgBadges.fetch();
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index e365f51567d..62aa5df888f 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -6,7 +6,7 @@ import Issue from '~/issue';
import initIncidentApp from '~/issue_show/incident';
import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
-import initNotesApp from '~/notes/index';
+import initNotesApp from '~/notes';
import { store } from '~/notes/stores';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
diff --git a/app/assets/javascripts/pages/projects/issues/show/index.js b/app/assets/javascripts/pages/projects/issues/show/index.js
index e4f99d1e7fd..1282d2aa303 100644
--- a/app/assets/javascripts/pages/projects/issues/show/index.js
+++ b/app/assets/javascripts/pages/projects/issues/show/index.js
@@ -1,7 +1,8 @@
+import { store } from '~/notes/stores';
import initRelatedIssues from '~/related_issues';
import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initShow from '../show';
initShow();
-initSidebarBundle();
+initSidebarBundle(store);
initRelatedIssues();
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
index 51980b2d971..51980b2d971 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_a.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab.vue
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
deleted file mode 100644
index 8f92ce95dbf..00000000000
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_b.vue
+++ /dev/null
@@ -1,116 +0,0 @@
-<script>
-import { GlProgressBar, GlSprintf } from '@gitlab/ui';
-import { pick } from 'lodash';
-import { s__ } from '~/locale';
-import { ACTION_LABELS } from '../constants';
-import LearnGitlabInfoCard from './learn_gitlab_info_card.vue';
-
-export default {
- components: { LearnGitlabInfoCard, GlProgressBar, GlSprintf },
- i18n: {
- title: s__('LearnGitLab|Learn GitLab'),
- description: s__(
- 'LearnGitLab|Ready to get started with GitLab? Follow these steps to set up your workspace, plan and commit changes, and deploy your project.',
- ),
- percentageCompleted: s__(`LearnGitLab|%{percentage}%{percentSymbol} completed`),
- workspace: {
- title: s__('LearnGitLab|Set up your workspace'),
- description: s__(
- "LearnGitLab|Complete these tasks first so you can enjoy GitLab's features to their fullest:",
- ),
- },
- plan: {
- title: s__('LearnGitLab|Plan and execute'),
- description: s__(
- 'LearnGitLab|Create a workflow for your new workspace, and learn how GitLab features work together:',
- ),
- },
- deploy: {
- title: s__('LearnGitLab|Deploy'),
- description: s__(
- 'LearnGitLab|Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:',
- ),
- },
- },
- props: {
- actions: {
- required: true,
- type: Object,
- },
- },
- maxValue: Object.keys(ACTION_LABELS).length,
- methods: {
- infoProps(action) {
- return {
- ...this.actions[action],
- ...pick(ACTION_LABELS[action], ['title', 'actionLabel', 'description', 'trialRequired']),
- };
- },
- progressValue() {
- return Object.values(this.actions).filter((a) => a.completed).length;
- },
- progressPercentage() {
- return Math.round((this.progressValue() / this.$options.maxValue) * 100);
- },
- },
-};
-</script>
-<template>
- <div>
- <div class="row">
- <div class="gl-mb-7 col-md-8 col-lg-7">
- <h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
- <p class="gl-text-gray-700 gl-mb-0">{{ $options.i18n.description }}</p>
- </div>
- </div>
-
- <div class="gl-mb-3">
- <p class="gl-text-gray-500 gl-mb-2" data-testid="completion-percentage">
- <gl-sprintf :message="$options.i18n.percentageCompleted">
- <template #percentage>{{ progressPercentage() }}</template>
- <template #percentSymbol>%</template>
- </gl-sprintf>
- </p>
- <gl-progress-bar :value="progressValue()" :max="$options.maxValue" />
- </div>
-
- <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.workspace.title }}</h2>
- <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.workspace.description }}</p>
-
- <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4">
- <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('userAdded')" /></div>
- <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('gitWrite')" /></div>
- <div class="col gl-mb-6">
- <learn-gitlab-info-card v-bind="infoProps('pipelineCreated')" />
- </div>
- <div class="col gl-mb-6"><learn-gitlab-info-card v-bind="infoProps('trialStarted')" /></div>
- <div class="col gl-mb-6">
- <learn-gitlab-info-card v-bind="infoProps('codeOwnersEnabled')" />
- </div>
- <div class="col gl-mb-6">
- <learn-gitlab-info-card v-bind="infoProps('requiredMrApprovalsEnabled')" />
- </div>
- </div>
-
- <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.plan.title }}</h2>
- <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.plan.description }}</p>
-
- <div class="row row-cols-2 row-cols-md-3 row-cols-lg-4">
- <div class="col gl-mb-6">
- <learn-gitlab-info-card v-bind="infoProps('issueCreated')" />
- </div>
- <div class="col gl-mb-6">
- <learn-gitlab-info-card v-bind="infoProps('mergeRequestCreated')" />
- </div>
- </div>
-
- <h2 class="gl-font-lg gl-mb-3">{{ $options.i18n.deploy.title }}</h2>
- <p class="gl-text-gray-700 gl-mb-6">{{ $options.i18n.deploy.description }}</p>
-
- <div class="row row-cols-2 row-cols-lg-4 g-2 g-lg-3">
- <div class="col gl-mb-6">
- <learn-gitlab-info-card v-bind="infoProps('securityScanEnabled')" />
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
index 3d31ac6c267..69fb5878f5c 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/components/learn_gitlab_section_link.vue
@@ -39,7 +39,7 @@ export default {
:href="value.url"
data-track-action="click_link"
:data-track-label="$options.i18n.ACTION_LABELS[action].title"
- data-track-property="Growth::Conversion::Experiment::LearnGitLabA"
+ data-track-property="Growth::Conversion::Experiment::LearnGitLab"
>
{{ $options.i18n.ACTION_LABELS[action].title }}
</gl-link>
diff --git a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
index ac7c94bdd9e..6da0a8fd212 100644
--- a/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
+++ b/app/assets/javascripts/pages/projects/learn_gitlab/index/index.js
@@ -1,8 +1,6 @@
import Vue from 'vue';
-import trackLearnGitlab from '~/learn_gitlab/track_learn_gitlab';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import LearnGitlabA from '../components/learn_gitlab_a.vue';
-import LearnGitlabB from '../components/learn_gitlab_b.vue';
+import LearnGitlab from '../components/learn_gitlab.vue';
function initLearnGitlab() {
const el = document.getElementById('js-learn-gitlab-app');
@@ -14,14 +12,10 @@ function initLearnGitlab() {
const actions = convertObjectPropsToCamelCase(JSON.parse(el.dataset.actions));
const sections = convertObjectPropsToCamelCase(JSON.parse(el.dataset.sections));
- const { learnGitlabA } = gon.experiments;
-
- trackLearnGitlab(learnGitlabA);
-
return new Vue({
el,
render(createElement) {
- return createElement(learnGitlabA ? LearnGitlabA : LearnGitlabB, {
+ return createElement(LearnGitlab, {
props: { actions, sections },
});
},
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 d6b6c9fe06a..dadf0988582 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,11 +2,10 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import loadAwardsHandler from '~/awards_handler';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
-import initPipelines from '~/commit/pipelines/pipelines_bundle';
+import { initPipelineCountListener } from '~/commit/pipelines/utils';
import initIssuableSidebar from '~/init_issuable_sidebar';
import StatusBox from '~/issuable/components/status_box.vue';
import createDefaultClient from '~/lib/graphql';
-import { handleLocationHash } from '~/lib/utils/common_utils';
import initSourcegraph from '~/sourcegraph';
import ZenMode from '~/zen_mode';
import getStateQuery from './queries/get_state.query.graphql';
@@ -15,11 +14,10 @@ export default function initMergeRequestShow() {
const awardEmojiEl = document.getElementById('js-vue-awards-block');
new ZenMode(); // eslint-disable-line no-new
- initIssuableSidebar();
- initPipelines();
+ initPipelineCountListener(document.querySelector('#commit-pipeline-table-view'));
new ShortcutsIssuable(true); // eslint-disable-line no-new
- handleLocationHash();
initSourcegraph();
+ initIssuableSidebar();
if (awardEmojiEl) {
import('~/emoji/awards_app')
.then((m) => m.default(awardEmojiEl))
@@ -29,7 +27,10 @@ export default function initMergeRequestShow() {
}
const el = document.querySelector('.js-mr-status-box');
- const apolloProvider = new VueApollo({ defaultClient: createDefaultClient() });
+ const apolloProvider = new VueApollo({
+ assumeImmutableResults: true,
+ defaultClient: createDefaultClient(),
+ });
// eslint-disable-next-line no-new
new Vue({
el,
diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
index 546fa66eda6..25dede33880 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js
@@ -5,8 +5,11 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initShow from '../init_merge_request_show';
-initShow();
-initSidebarBundle();
initMrNotes();
-initReviewBar();
-initIssuableHeaderWarning(store);
+initShow();
+
+requestIdleCallback(() => {
+ initSidebarBundle(store);
+ initReviewBar();
+ initIssuableHeaderWarning(store);
+});
diff --git a/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue
new file mode 100644
index 00000000000..ba8858c985a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/new/components/new_project_url_select.vue
@@ -0,0 +1,98 @@
+<script>
+import {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import Tracking from '~/tracking';
+import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
+import searchNamespacesWhereUserCanCreateProjectsQuery from '../queries/search_namespaces_where_user_can_create_projects.query.graphql';
+
+export default {
+ components: {
+ GlButton,
+ GlButtonGroup,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownSectionHeader,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ },
+ mixins: [Tracking.mixin()],
+ apollo: {
+ currentUser: {
+ query: searchNamespacesWhereUserCanCreateProjectsQuery,
+ variables() {
+ return {
+ search: this.search,
+ };
+ },
+ skip() {
+ return this.search.length > 0 && this.search.length < MINIMUM_SEARCH_LENGTH;
+ },
+ debounce: DEBOUNCE_DELAY,
+ },
+ },
+ inject: ['namespaceFullPath', 'namespaceId', 'rootUrl', 'trackLabel'],
+ data() {
+ return {
+ currentUser: {},
+ search: '',
+ selectedNamespace: {
+ id: this.namespaceId,
+ fullPath: this.namespaceFullPath,
+ },
+ };
+ },
+ computed: {
+ userGroups() {
+ return this.currentUser.groups?.nodes || [];
+ },
+ userNamespace() {
+ return this.currentUser.namespace || {};
+ },
+ },
+ methods: {
+ handleClick({ id, fullPath }) {
+ this.selectedNamespace = {
+ id: getIdFromGraphQLId(id),
+ fullPath,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button-group class="gl-w-full">
+ <gl-button label>{{ rootUrl }}</gl-button>
+ <gl-dropdown
+ class="gl-w-full"
+ :text="selectedNamespace.fullPath"
+ toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base!"
+ data-qa-selector="select_namespace_dropdown"
+ @show="track('activate_form_input', { label: trackLabel, property: 'project_path' })"
+ >
+ <gl-search-box-by-type v-model.trim="search" />
+ <gl-loading-icon v-if="$apollo.queries.currentUser.loading" />
+ <template v-else>
+ <gl-dropdown-section-header>{{ __('Groups') }}</gl-dropdown-section-header>
+ <gl-dropdown-item v-for="group of userGroups" :key="group.id" @click="handleClick(group)">
+ {{ group.fullPath }}
+ </gl-dropdown-item>
+ <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header>
+ <gl-dropdown-item @click="handleClick(userNamespace)">
+ {{ userNamespace.fullPath }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+
+ <input type="hidden" name="project[namespace_id]" :value="selectedNamespace.id" />
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js
index f469c56e808..ed816e3be95 100644
--- a/app/assets/javascripts/pages/projects/new/index.js
+++ b/app/assets/javascripts/pages/projects/new/index.js
@@ -1,13 +1,15 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import initProjectVisibilitySelector from '../../../project_visibility';
import initProjectNew from '../../../projects/project_new';
import NewProjectCreationApp from './components/app.vue';
+import NewProjectUrlSelect from './components/new_project_url_select.vue';
-initProjectVisibilitySelector();
-initProjectNew.bindEvents();
+function initNewProjectCreation() {
+ const el = document.querySelector('.js-new-project-creation');
-function initNewProjectCreation(el) {
const {
pushToCreateProjectCommand,
workingWithProjectsHelpPath,
@@ -29,9 +31,6 @@ function initNewProjectCreation(el) {
return new Vue({
el,
- components: {
- NewProjectCreationApp,
- },
provide,
render(h) {
return h(NewProjectCreationApp, { props });
@@ -39,6 +38,31 @@ function initNewProjectCreation(el) {
});
}
-const el = document.querySelector('.js-new-project-creation');
+function initNewProjectUrlSelect() {
+ const el = document.querySelector('.js-vue-new-project-url-select');
+
+ if (!el) {
+ return undefined;
+ }
-initNewProjectCreation(el);
+ Vue.use(VueApollo);
+
+ return new Vue({
+ el,
+ apolloProvider: new VueApollo({
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ }),
+ provide: {
+ namespaceFullPath: el.dataset.namespaceFullPath,
+ namespaceId: el.dataset.namespaceId,
+ rootUrl: el.dataset.rootUrl,
+ trackLabel: el.dataset.trackLabel,
+ },
+ render: (createElement) => createElement(NewProjectUrlSelect),
+ });
+}
+
+initProjectVisibilitySelector();
+initProjectNew.bindEvents();
+initNewProjectCreation();
+initNewProjectUrlSelect();
diff --git a/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql b/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
new file mode 100644
index 00000000000..e16fe5dde49
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql
@@ -0,0 +1,14 @@
+query searchNamespacesWhereUserCanCreateProjects($search: String) {
+ currentUser {
+ groups(permissionScope: CREATE_PROJECTS, search: $search) {
+ nodes {
+ id
+ fullPath
+ }
+ }
+ namespace {
+ id
+ fullPath
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/packages/packages/show/index.js b/app/assets/javascripts/pages/projects/packages/packages/show/index.js
index ee06f247ddc..2dee87985cb 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/show/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/show/index.js
@@ -1,11 +1,3 @@
-(async function initPackage() {
- let app;
- if (document.getElementById('js-vue-packages-detail-new')) {
- app = await import(
- /* webpackChunkName: 'new_package_app' */ `~/packages_and_registries/package_registry/pages/details.js`
- );
- } else {
- app = await import('~/packages/details/');
- }
- app.default();
-})();
+import initPackageDetails from '~/packages_and_registries/package_registry/pages/details';
+
+initPackageDetails();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index d0ec5668d21..0e646e8c505 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -141,9 +141,7 @@ export default {
return Math.floor(Math.random() * 28);
},
showDailyLimitMessage({ value }) {
- return (
- value === KEY_CUSTOM && this.glFeatures.ciDailyLimitForPipelineSchedules && this.dailyLimit
- );
+ return value === KEY_CUSTOM && this.dailyLimit;
},
},
};
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 92b2bc9644b..42b08bcaa7b 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -53,7 +53,7 @@ Those scheduled pipelines will inherit limited project access based on their ass
<p>
{{ __('Learn more in the') }}
<a :href="docsUrl" target="_blank" rel="nofollow">
- {{ s__('Learn more in the|pipeline schedules documentation') }}</a
+ {{ __('pipeline schedules documentation') }}</a
>.
<!-- oneline to prevent extra space before period -->
</p>
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index fb0be31834d..0b662c945c6 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,4 +1,5 @@
import groupsSelect from '~/groups_select';
+import initImportAProjectModal from '~/invite_members/init_import_a_project_modal';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
@@ -14,6 +15,7 @@ import UsersSelect from '~/users_select';
groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
+initImportAProjectModal();
initInviteMembersModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js
new file mode 100644
index 00000000000..9cd80b85c8a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js
@@ -0,0 +1,23 @@
+import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
+import storageCounter from '~/projects/storage_counter';
+import initSearchSettings from '~/search_settings';
+
+const initLinkedTabs = () => {
+ if (!document.querySelector('.js-usage-quota-tabs')) {
+ return false;
+ }
+
+ return new LinkedTabs({
+ defaultAction: '#storage-quota-tab',
+ parentEl: '.js-usage-quota-tabs',
+ hashedTabs: true,
+ });
+};
+
+const initVueApp = () => {
+ storageCounter('js-project-storage-count-app');
+};
+
+initVueApp();
+initLinkedTabs();
+initSearchSettings();
diff --git a/app/assets/javascripts/pages/projects/wikis/index.js b/app/assets/javascripts/pages/projects/wikis/index.js
index dead61cf358..2c1f9e634ab 100644
--- a/app/assets/javascripts/pages/projects/wikis/index.js
+++ b/app/assets/javascripts/pages/projects/wikis/index.js
@@ -1,3 +1,5 @@
+import { initDiffStatsDropdown } from '~/init_diff_stats_dropdown';
import initWikis from '~/pages/shared/wikis';
initWikis();
+initDiffStatsDropdown();
diff --git a/app/assets/javascripts/pages/projects/work_items/index/index.js b/app/assets/javascripts/pages/projects/work_items/index/index.js
new file mode 100644
index 00000000000..11c257611f0
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/work_items/index/index.js
@@ -0,0 +1,3 @@
+import { initWorkItemsRoot } from '~/work_items/index';
+
+initWorkItemsRoot();
diff --git a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
index 1e7c29aefaa..7e646125331 100644
--- a/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
+++ b/app/assets/javascripts/pages/sessions/new/signin_tabs_memoizer.js
@@ -8,7 +8,7 @@ export default class SigninTabsMemoizer {
constructor({ currentTabKey = 'current_signin_tab', tabSelector = 'ul.new-session-tabs' } = {}) {
this.currentTabKey = currentTabKey;
this.tabSelector = tabSelector;
- this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
// sets selected tab if given as hash tag
if (window.location.hash) {
this.saveData(window.location.hash);
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 670b0535ca3..f204f0ebfaa 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,5 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
+import { GlSafeHtmlDirective } from '@gitlab/ui';
import { glEmojiTag } from '~/emoji';
import { s__ } from '~/locale';
@@ -13,6 +13,9 @@ export default {
DetailedMetric,
RequestSelector,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
store: {
type: Object,
@@ -129,6 +132,7 @@ export default {
this.currentRequest = newRequestId;
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
@@ -144,7 +148,7 @@ export default {
class="current-host"
:class="{ canary: currentRequest.details.host.canary }"
>
- <span v-html="birdEmoji"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="birdEmoji"></span>
{{ currentRequest.details.host.hostname }}
</span>
</div>
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index 75fb7bbc5c5..a46ac620f48 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlPopover } from '@gitlab/ui';
+import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui';
import { glEmojiTag } from '~/emoji';
import { n__ } from '~/locale';
@@ -8,6 +7,9 @@ export default {
components: {
GlPopover,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
currentRequest: {
type: Object,
@@ -43,6 +45,7 @@ export default {
methods: {
glEmojiTag,
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
@@ -59,7 +62,10 @@ export default {
</option>
</select>
<span v-if="requestsWithWarnings.length" class="gl-cursor-default">
- <span id="performance-bar-request-selector-warning" v-html="glEmojiTag('warning')"></span>
+ <span
+ id="performance-bar-request-selector-warning"
+ v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('warning')"
+ ></span>
<gl-popover
placement="bottom"
target="performance-bar-request-selector-warning"
diff --git a/app/assets/javascripts/performance_bar/components/request_warning.vue b/app/assets/javascripts/performance_bar/components/request_warning.vue
index 7fe6b088ebb..3ebd222029b 100644
--- a/app/assets/javascripts/performance_bar/components/request_warning.vue
+++ b/app/assets/javascripts/performance_bar/components/request_warning.vue
@@ -1,12 +1,14 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlPopover } from '@gitlab/ui';
+import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui';
import { glEmojiTag } from '~/emoji';
export default {
components: {
GlPopover,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
htmlId: {
type: String,
@@ -32,11 +34,12 @@ export default {
methods: {
glEmojiTag,
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
<template>
<span v-if="hasWarnings" class="gl-cursor-default">
- <span :id="htmlId" v-html="glEmojiTag('warning')"></span>
+ <span :id="htmlId" v-safe-html:[$options.safeHtmlConfig]="glEmojiTag('warning')"></span>
<gl-popover placement="bottom" :target="htmlId" :content="warningMessage" />
</span>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
index 8f4894a0bde..0308cd9c565 100644
--- a/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
+++ b/app/assets/javascripts/pipeline_editor/components/commit/commit_section.vue
@@ -10,7 +10,6 @@ import {
import commitCIFile from '../../graphql/mutations/commit_ci_file.mutation.graphql';
import updateCurrentBranchMutation from '../../graphql/mutations/update_current_branch.mutation.graphql';
import updateLastCommitBranchMutation from '../../graphql/mutations/update_last_commit_branch.mutation.graphql';
-import getCommitSha from '../../graphql/queries/client/commit_sha.graphql';
import getCurrentBranch from '../../graphql/queries/client/current_branch.graphql';
import getIsNewCiConfigFile from '../../graphql/queries/client/is_new_ci_config_file.graphql';
import getPipelineEtag from '../../graphql/queries/client/pipeline_etag.graphql';
@@ -37,6 +36,11 @@ export default {
type: String,
required: true,
},
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -49,9 +53,6 @@ export default {
isNewCiConfigFile: {
query: getIsNewCiConfigFile,
},
- commitSha: {
- query: getCommitSha,
- },
currentBranch: {
query: getCurrentBranch,
},
@@ -96,13 +97,7 @@ export default {
lastCommitId: this.commitSha,
},
update(store, { data }) {
- const commitSha = data?.commitCreate?.commit?.sha;
const pipelineEtag = data?.commitCreate?.commit?.commitPipelinePath;
-
- if (commitSha) {
- store.writeQuery({ query: getCommitSha, data: { commitSha } });
- }
-
if (pipelineEtag) {
store.writeQuery({ query: getPipelineEtag, data: { pipelineEtag } });
}
@@ -117,6 +112,9 @@ export default {
this.$emit('commit', { type: COMMIT_SUCCESS });
this.updateLastCommitBranch(targetBranch);
this.updateCurrentBranch(targetBranch);
+ if (this.currentBranch === targetBranch) {
+ this.$emit('updateCommitSha');
+ }
}
} catch (error) {
this.$emit('showError', { type: COMMIT_FAILURE, reasons: [error?.message] });
diff --git a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
index 77ede396496..f2a0f474bc4 100644
--- a/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/pipeline_editor/components/editor/text_editor.vue
@@ -3,7 +3,6 @@ import { EDITOR_READY_EVENT } from '~/editor/constants';
import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import getCommitSha from '../../graphql/queries/client/commit_sha.graphql';
export default {
components: {
@@ -12,14 +11,11 @@ export default {
mixins: [glFeatureFlagMixin()],
inject: ['ciConfigPath', 'projectPath', 'projectNamespace', 'defaultBranch'],
inheritAttrs: false,
- data() {
- return {
- commitSha: '',
- };
- },
- apollo: {
+ props: {
commitSha: {
- query: getCommitSha,
+ type: String,
+ required: false,
+ default: '',
},
},
methods: {
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
index 9a6eed50fbe..68065cc3c73 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/branch_switcher.vue
@@ -158,11 +158,9 @@ export default {
const updatedPath = setUrlParams({ branch_name: newBranch });
historyPushState(updatedPath);
- this.$emit('updateCommitSha', { newBranch });
-
// refetching the content will cause a lot of components to re-render,
// including the text editor which uses the commit sha to register the CI schema
- // so we need to make sure the commit sha is updated first
+ // so we need to make sure the currentBranch (and consequently, the commitSha) are updated first
await this.$nextTick();
this.$emit('refetchContent');
},
diff --git a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
index ebe73bdcec3..551a0430fbf 100644
--- a/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
+++ b/app/assets/javascripts/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue
@@ -1,21 +1,14 @@
<script>
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import BranchSwitcher from './branch_switcher.vue';
export default {
components: {
BranchSwitcher,
},
- mixins: [glFeatureFlagsMixin()],
- computed: {
- showBranchSwitcher() {
- return this.glFeatures.pipelineEditorBranchSwitcher;
- },
- },
};
</script>
<template>
<div class="gl-mb-4">
- <branch-switcher v-if="showBranchSwitcher" v-on="$listeners" />
+ <branch-switcher v-on="$listeners" />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
index 24bca04e115..fcc31f087ff 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_editor_header.vue
@@ -33,6 +33,11 @@ export default {
type: Object,
required: true,
},
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
isNewCiConfigFile: {
type: Boolean,
required: true,
@@ -54,7 +59,11 @@ export default {
</script>
<template>
<div class="gl-mb-5">
- <pipeline-status v-if="showPipelineStatus" :class="$options.pipelineStatusClasses" />
+ <pipeline-status
+ v-if="showPipelineStatus"
+ :commit-sha="commitSha"
+ :class="$options.pipelineStatusClasses"
+ />
<validation-segment :class="validationStyling" :ci-config="ciConfigData" />
</div>
</template>
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index 46f6f4a28c1..ec240854be5 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -3,7 +3,6 @@ import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
-import getCommitSha from '~/pipeline_editor/graphql/queries/client/commit_sha.graphql';
import getPipelineQuery from '~/pipeline_editor/graphql/queries/client/pipeline.graphql';
import getPipelineEtag from '~/pipeline_editor/graphql/queries/client/pipeline_etag.graphql';
import {
@@ -33,10 +32,14 @@ export default {
GlSprintf,
},
inject: ['projectFullPath'],
- apollo: {
+ props: {
commitSha: {
- query: getCommitSha,
+ type: String,
+ required: false,
+ default: '',
},
+ },
+ apollo: {
pipelineEtag: {
query: getPipelineEtag,
},
@@ -51,7 +54,7 @@ export default {
sha: this.commitSha,
};
},
- update: (data) => {
+ update(data) {
const { id, commitPath = '', detailedStatus = {} } = data.project?.pipeline || {};
return {
@@ -60,6 +63,11 @@ export default {
detailedStatus,
};
},
+ result(res) {
+ if (res.data?.project?.pipeline) {
+ this.hasError = false;
+ }
+ },
error() {
this.hasError = true;
},
@@ -68,7 +76,6 @@ export default {
},
data() {
return {
- commitSha: '',
hasError: false,
};
},
@@ -84,7 +91,11 @@ export default {
// (e.g. pipeline is null during fetch when the pipeline hasn't been
// triggered yet), we can just show the loading state until the pipeline
// details are ready to be fetched
- return this.$apollo.queries.pipeline.loading || (!this.hasPipelineData && !this.hasError);
+ return (
+ this.$apollo.queries.pipeline.loading ||
+ this.commitSha.length === 0 ||
+ (!this.hasPipelineData && !this.hasError)
+ );
},
shortSha() {
return truncateSha(this.commitSha);
diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
index e463fcf379d..f7c9f10ea46 100644
--- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
+++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue
@@ -69,6 +69,11 @@ export default {
type: String,
required: true,
},
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
apollo: {
appStatus: {
@@ -110,7 +115,7 @@ export default {
@click="setCurrentTab($options.tabConstants.CREATE_TAB)"
>
<ci-editor-header />
- <text-editor :value="ciFileContent" v-on="$listeners" />
+ <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" />
</editor-tab>
<editor-tab
class="gl-mb-3"
diff --git a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
index 0ac4a40ff4a..fbb66231f16 100644
--- a/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
+++ b/app/assets/javascripts/pipeline_editor/components/ui/pipeline_editor_empty_state.vue
@@ -24,9 +24,6 @@ export default {
},
},
computed: {
- showFileNav() {
- return this.glFeatures.pipelineEditorBranchSwitcher;
- },
showCTAButton() {
return this.glFeatures.pipelineEditorEmptyStateAction;
},
@@ -40,7 +37,7 @@ export default {
</script>
<template>
<div>
- <pipeline-editor-file-nav v-if="showFileNav" v-on="$listeners" />
+ <pipeline-editor-file-nav v-on="$listeners" />
<div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11">
<img :src="emptyStateIllustrationPath" />
<h1 class="gl-font-size-h1">{{ $options.i18n.title }}</h1>
diff --git a/app/assets/javascripts/pipeline_editor/constants.js b/app/assets/javascripts/pipeline_editor/constants.js
index d05b06d16db..bb03fa126a5 100644
--- a/app/assets/javascripts/pipeline_editor/constants.js
+++ b/app/assets/javascripts/pipeline_editor/constants.js
@@ -43,3 +43,5 @@ export const pipelineEditorTrackingOptions = {
export const TEMPLATE_REPOSITORY_URL =
'https://gitlab.com/gitlab-org/gitlab-foss/tree/master/lib/gitlab/ci/templates';
+
+export const COMMIT_SHA_POLL_INTERVAL = 1000;
diff --git a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql b/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql
deleted file mode 100644
index dce17cad808..00000000000
--- a/app/assets/javascripts/pipeline_editor/graphql/mutations/update_commit_sha.mutation.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-mutation updateCommitSha($commitSha: String) {
- updateCommitSha(commitSha: $commitSha) @client
-}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/client/commit_sha.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/client/commit_sha.graphql
deleted file mode 100644
index 6c7635887ec..00000000000
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/client/commit_sha.graphql
+++ /dev/null
@@ -1,3 +0,0 @@
-query getCommitSha {
- commitSha @client
-}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
index 219c23bb22b..02d49507947 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
+++ b/app/assets/javascripts/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql
@@ -1,11 +1,10 @@
query getLatestCommitSha($projectPath: ID!, $ref: String) {
project(fullPath: $projectPath) {
- pipelines(ref: $ref) {
- nodes {
- id
- sha
- path
- commitPath
+ repository {
+ tree(ref: $ref) {
+ lastCommit {
+ sha
+ }
}
}
}
diff --git a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
index 2bec2006e95..a34652b1495 100644
--- a/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
+++ b/app/assets/javascripts/pipeline_editor/graphql/resolvers.js
@@ -1,6 +1,5 @@
import produce from 'immer';
import axios from '~/lib/utils/axios_utils';
-import getCommitShaQuery from './queries/client/commit_sha.graphql';
import getCurrentBranchQuery from './queries/client/current_branch.graphql';
import getLastCommitBranchQuery from './queries/client/last_commit_branch.query.graphql';
@@ -32,14 +31,6 @@ export const resolvers = {
__typename: 'CiLintContent',
}));
},
- updateCommitSha: (_, { commitSha }, { cache }) => {
- cache.writeQuery({
- query: getCommitShaQuery,
- data: produce(cache.readQuery({ query: getCommitShaQuery }), (draftData) => {
- draftData.commitSha = commitSha;
- }),
- });
- },
updateCurrentBranch: (_, { currentBranch }, { cache }) => {
cache.writeQuery({
query: getCurrentBranchQuery,
diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js
index e0f8d889cad..89b9091e6f9 100644
--- a/app/assets/javascripts/pipeline_editor/index.js
+++ b/app/assets/javascripts/pipeline_editor/index.js
@@ -4,7 +4,6 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
import { CODE_SNIPPET_SOURCE_SETTINGS } from './components/code_snippet_alert/constants';
-import getCommitSha from './graphql/queries/client/commit_sha.graphql';
import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
import getLastCommitBranchQuery from './graphql/queries/client/last_commit_branch.query.graphql';
import getPipelineEtag from './graphql/queries/client/pipeline_etag.graphql';
@@ -26,7 +25,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
const {
// Add to apollo cache as it can be updated by future queries
- commitSha,
initialBranchName,
pipelineEtag,
// Add to provide/inject API for static values
@@ -58,7 +56,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(resolvers, { typeDefs, useGet: true }),
+ defaultClient: createDefaultClient(resolvers, {
+ typeDefs,
+ useGet: true,
+ assumeImmutableResults: true,
+ }),
});
const { cache } = apolloProvider.clients.defaultClient;
@@ -70,13 +72,6 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => {
});
cache.writeQuery({
- query: getCommitSha,
- data: {
- commitSha,
- },
- });
-
- cache.writeQuery({
query: getPipelineEtag,
data: {
pipelineEtag,
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
index 0e8a6805a59..e70417145ab 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue
@@ -10,17 +10,16 @@ import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes
import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue';
import PipelineEditorMessages from './components/ui/pipeline_editor_messages.vue';
import {
+ COMMIT_SHA_POLL_INTERVAL,
EDITOR_APP_STATUS_EMPTY,
EDITOR_APP_STATUS_ERROR,
EDITOR_APP_STATUS_LOADING,
LOAD_FAILURE_UNKNOWN,
STARTER_TEMPLATE_NAME,
} from './constants';
-import updateCommitShaMutation from './graphql/mutations/update_commit_sha.mutation.graphql';
import getBlobContent from './graphql/queries/blob_content.graphql';
import getCiConfigData from './graphql/queries/ci_config.graphql';
import getAppStatus from './graphql/queries/client/app_status.graphql';
-import getCommitSha from './graphql/queries/client/commit_sha.graphql';
import getCurrentBranch from './graphql/queries/client/current_branch.graphql';
import getIsNewCiConfigFile from './graphql/queries/client/is_new_ci_config_file.graphql';
import getTemplate from './graphql/queries/get_starter_template.query.graphql';
@@ -50,6 +49,7 @@ export default {
failureType: null,
failureReasons: [],
initialCiFileContent: '',
+ isFetchingCommitSha: false,
isNewCiConfigFile: false,
lastCommittedContent: '',
currentCiFileContent: '',
@@ -136,7 +136,7 @@ export default {
update(data) {
const { ciConfig } = data || {};
const stageNodes = ciConfig?.stages?.nodes || [];
- const stages = unwrapStagesWithNeeds(stageNodes);
+ const stages = unwrapStagesWithNeeds(JSON.parse(JSON.stringify(stageNodes)));
return { ...ciConfig, stages };
},
@@ -156,7 +156,25 @@ export default {
query: getAppStatus,
},
commitSha: {
- query: getCommitSha,
+ query: getLatestCommitShaQuery,
+ variables() {
+ return {
+ projectPath: this.projectFullPath,
+ ref: this.currentBranch,
+ };
+ },
+ update(data) {
+ const latestCommitSha = data.project?.repository?.tree?.lastCommit?.sha;
+
+ if (this.isFetchingCommitSha && latestCommitSha === this.commitSha) {
+ this.$apollo.queries.commitSha.startPolling(COMMIT_SHA_POLL_INTERVAL);
+ return this.commitSha;
+ }
+
+ this.isFetchingCommitSha = false;
+ this.$apollo.queries.commitSha.stopPolling();
+ return latestCommitSha;
+ },
},
currentBranch: {
query: getCurrentBranch,
@@ -257,37 +275,9 @@ export default {
updateCiConfig(ciFileContent) {
this.currentCiFileContent = ciFileContent;
},
- async updateCommitSha({ newBranch }) {
- let fetchResults;
-
- try {
- fetchResults = await this.$apollo.query({
- query: getLatestCommitShaQuery,
- variables: {
- projectPath: this.projectFullPath,
- ref: newBranch,
- },
- });
- } catch {
- this.showFetchError();
- return;
- }
-
- if (fetchResults.errors?.length > 0) {
- this.showFetchError();
- return;
- }
-
- const pipelineNodes = fetchResults?.data?.project?.pipelines?.nodes ?? [];
- if (pipelineNodes.length === 0) {
- return;
- }
-
- const commitSha = pipelineNodes[0].sha;
- this.$apollo.mutate({
- mutation: updateCommitShaMutation,
- variables: { commitSha },
- });
+ updateCommitSha() {
+ this.isFetchingCommitSha = true;
+ this.$apollo.queries.commitSha.refetch();
},
updateOnCommit({ type }) {
this.reportSuccess(type);
@@ -336,6 +326,7 @@ export default {
:ci-config-data="ciConfigData"
:ci-file-content="currentCiFileContent"
:is-new-ci-config-file="isNewCiConfigFile"
+ :commit-sha="commitSha"
@commit="updateOnCommit"
@resetContent="resetContent"
@showError="showErrorAlert"
diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
index dfe9c82b912..4324c64ab3b 100644
--- a/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
+++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_home.vue
@@ -25,6 +25,11 @@ export default {
type: String,
required: true,
},
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
isNewCiConfigFile: {
type: Boolean,
required: true,
@@ -56,15 +61,22 @@ export default {
<pipeline-editor-file-nav v-on="$listeners" />
<pipeline-editor-header
:ci-config-data="ciConfigData"
+ :commit-sha="commitSha"
:is-new-ci-config-file="isNewCiConfigFile"
/>
<pipeline-editor-tabs
:ci-config-data="ciConfigData"
:ci-file-content="ciFileContent"
+ :commit-sha="commitSha"
v-on="$listeners"
@set-current-tab="setCurrentTab"
/>
- <commit-section v-if="showCommitForm" :ci-file-content="ciFileContent" v-on="$listeners" />
+ <commit-section
+ v-if="showCommitForm"
+ :ci-file-content="ciFileContent"
+ :commit-sha="commitSha"
+ v-on="$listeners"
+ />
<pipeline-editor-drawer v-if="showPipelineDrawer" />
</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 5472e51445a..d74b6e8edf6 100644
--- a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
+++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue
@@ -123,6 +123,7 @@ export default {
isWarningDismissed: false,
isLoading: false,
submitted: false,
+ ccAlertDismissed: false,
};
},
computed: {
@@ -151,7 +152,7 @@ export default {
return this.form[this.refFullName]?.descriptions ?? {};
},
ccRequiredError() {
- return this.error === CC_VALIDATION_REQUIRED_ERROR;
+ return this.error === CC_VALIDATION_REQUIRED_ERROR && !this.ccAlertDismissed;
},
},
watch: {
@@ -292,6 +293,7 @@ export default {
},
createPipeline() {
this.submitted = true;
+ this.ccAlertDismissed = false;
return axios
.post(this.pipelinesPath, {
@@ -333,13 +335,17 @@ export default {
this.warnings = warnings;
this.totalWarnings = totalWarnings;
},
+ dismissError() {
+ this.ccAlertDismissed = true;
+ this.error = null;
+ },
},
};
</script>
<template>
<gl-form @submit.prevent="createPipeline">
- <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" />
+ <cc-validation-required-alert v-if="ccRequiredError" class="gl-pb-5" @dismiss="dismissError" />
<gl-alert
v-else-if="error"
:title="errorTitle"
diff --git a/app/assets/javascripts/pipelines/components/graph/accessors.js b/app/assets/javascripts/pipelines/components/graph/accessors.js
deleted file mode 100644
index 6ece855bcd8..00000000000
--- a/app/assets/javascripts/pipelines/components/graph/accessors.js
+++ /dev/null
@@ -1,25 +0,0 @@
-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/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js
index dd9cdae518f..0b59612b25c 100644
--- a/app/assets/javascripts/pipelines/components/graph/constants.js
+++ b/app/assets/javascripts/pipelines/components/graph/constants.js
@@ -8,9 +8,6 @@ export const UPSTREAM = 'upstream';
*/
export const ONE_COL_WIDTH = 180;
-export const REST = 'rest';
-export const GRAPHQL = 'graphql';
-
export const STAGE_VIEW = 'stage';
export const LAYER_VIEW = 'layer';
export const VIEW_TYPE_KEY = 'pipeline_graph_view_type';
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 b2a3f27e079..6f4360649ff 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -23,6 +23,11 @@ export default {
required: false,
default: -1,
},
+ cssClassJobName: {
+ type: [String, Array],
+ required: false,
+ default: '',
+ },
stageName: {
type: String,
required: false,
@@ -59,7 +64,8 @@ export default {
type="button"
data-toggle="dropdown"
data-display="static"
- class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!"
+ :class="cssClassJobName"
+ class="dropdown-menu-toggle gl-pipeline-job-width! gl-pr-4!"
>
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
<job-item
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 6584d89d87c..fd40ca0b9c9 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -7,8 +7,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils';
import ActionComponent from '../jobs_shared/action_component.vue';
import JobNameComponent from '../jobs_shared/job_name_component.vue';
-import { accessValue } from './accessors';
-import { REST, SINGLE_JOB } from './constants';
+import { SINGLE_JOB } from './constants';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
@@ -47,18 +46,13 @@ export default {
GlTooltip: GlTooltipDirective,
},
mixins: [delayedJobMixin],
- inject: {
- dataMethod: {
- default: REST,
- },
- },
props: {
job: {
type: Object,
required: true,
},
cssClassJobName: {
- type: String,
+ type: [String, Array],
required: false,
default: '',
},
@@ -111,10 +105,10 @@ export default {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
},
detailsPath() {
- return accessValue(this.dataMethod, 'detailsPath', this.status);
+ return this.status.detailsPath;
},
hasDetails() {
- return accessValue(this.dataMethod, 'hasDetails', this.status);
+ return this.status.hasDetails;
},
isSingleItem() {
return this.type === SINGLE_JOB;
@@ -189,7 +183,7 @@ export default {
if (this.isSingleItem) {
/*
This is so the jobDropdown still toggles. Issue to refactor:
- https://gitlab.com/gitlab-org/gitlab/-/issues/267117
+ https://gitlab.com/gitlab-org/gitlab/-/issues/267117
*/
evt.stopPropagation();
}
@@ -226,11 +220,11 @@ export default {
<div class="ci-job-name-component gl-display-flex gl-align-items-center">
<ci-icon :size="24" :status="job.status" class="gl-line-height-0" />
<div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full">
- <div class="gl-text-truncate mw-70p gl-line-height-normal">{{ job.name }}</div>
+ <div class="gl-text-truncate gl-w-70p gl-line-height-normal">{{ job.name }}</div>
<div
v-if="showStageName"
data-testid="stage-name-in-job"
- class="gl-text-truncate mw-70p gl-font-sm gl-text-gray-500 gl-line-height-normal"
+ class="gl-text-truncate gl-w-70p gl-font-sm gl-text-gray-500 gl-line-height-normal"
>
{{ stageName }}
</div>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index dd8a354511a..be47799868b 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -4,8 +4,7 @@ import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils';
-import { accessValue } from './accessors';
-import { DOWNSTREAM, REST, UPSTREAM } from './constants';
+import { DOWNSTREAM, UPSTREAM } from './constants';
export default {
directives: {
@@ -18,11 +17,6 @@ export default {
GlLoadingIcon,
GlBadge,
},
- inject: {
- dataMethod: {
- default: REST,
- },
- },
props: {
columnTitle: {
type: String,
@@ -40,20 +34,9 @@ export default {
type: String,
required: true,
},
- /*
- 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,
+ required: true,
},
},
computed: {
@@ -65,7 +48,7 @@ export default {
return `js-linked-pipeline-${this.pipeline.id}`;
},
pipelineStatus() {
- return accessValue(this.dataMethod, 'pipelineStatus', this.pipeline);
+ return this.pipeline.status;
},
projectName() {
return this.pipeline.project.name;
@@ -97,12 +80,10 @@ export default {
return this.type === UPSTREAM;
},
isSameProject() {
- return this.projectId > -1
- ? this.projectId === this.pipeline.project.id
- : !this.pipeline.multiproject;
+ return !this.pipeline.multiproject;
},
sourceJobName() {
- return accessValue(this.dataMethod, 'sourceJob', this.pipeline);
+ return this.pipeline.sourceJob?.name ?? '';
},
sourceJobInfo() {
return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : '';
@@ -143,9 +124,8 @@ export default {
<div
ref="linkedPipeline"
v-gl-tooltip
- class="linked-pipeline build gl-pipeline-job-width"
+ class="gl-pipeline-job-width"
:title="tooltipText"
- :class="{ 'downstream-pipeline': isDownstream }"
data-qa-selector="child_pipeline"
@mouseover="onDownstreamHovered"
@mouseleave="onDownstreamHoverLeave"
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 d251e0d8bd8..3c1208afbf0 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -195,7 +195,7 @@ export default {
<template>
<div class="gl-display-flex">
<div :class="columnClass" class="linked-pipelines-column">
- <div data-testid="linked-column-title" class="stage-name" :class="computedTitleClasses">
+ <div data-testid="linked-column-title" :class="computedTitleClasses">
{{ columnTitle }}
</div>
<ul class="gl-pl-0">
@@ -224,7 +224,7 @@ export default {
<pipeline-graph
v-if="isExpanded(pipeline.id)"
:type="type"
- class="d-inline-block gl-mt-n2"
+ class="gl-inline-block gl-mt-n2"
:config-paths="configPaths"
:pipeline="currentPipeline"
:computed-pipeline-info="getPipelineLayers(pipeline.id)"
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 d34ae8036ed..b0f375c9aeb 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -4,8 +4,6 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { reportToSentry } from '../../utils';
import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
import ActionComponent from '../jobs_shared/action_component.vue';
-import { accessValue } from './accessors';
-import { GRAPHQL } from './constants';
import JobGroupDropdown from './job_group_dropdown.vue';
import JobItem from './job_item.vue';
@@ -65,6 +63,21 @@ export default {
required: true,
},
},
+ jobClasses: [
+ 'gl-py-3',
+ 'gl-px-4',
+ 'gl-border-gray-100',
+ 'gl-border-solid',
+ 'gl-border-1',
+ 'gl-bg-white',
+ 'gl-rounded-7',
+ 'gl-hover-bg-gray-50',
+ 'gl-focus-bg-gray-50',
+ 'gl-hover-text-gray-900',
+ 'gl-focus-text-gray-900',
+ 'gl-hover-border-gray-200',
+ 'gl-focus-border-gray-200',
+ ],
titleClasses: [
'gl-font-weight-bold',
'gl-pipeline-job-width',
@@ -97,7 +110,7 @@ export default {
},
methods: {
getGroupId(group) {
- return accessValue(GRAPHQL, 'groupId', group);
+ return group.name;
},
groupId(group) {
return `ci-badge-${escape(group.name)}`;
@@ -134,7 +147,7 @@ export default {
:action-icon="action.icon"
:tooltip-text="action.title"
:link="action.path"
- class="js-stage-action stage-action rounded"
+ class="js-stage-action"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/>
</div>
@@ -157,7 +170,7 @@ export default {
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
:stage-name="showStageName ? group.stageName : ''"
- css-class-job-name="gl-build-content"
+ :css-class-job-name="$options.jobClasses"
:class="[
{ 'gl-opacity-3': isFadedOut(group.name) },
'gl-transition-duration-slow gl-transition-timing-function-ease',
@@ -169,6 +182,7 @@ export default {
:group="group"
:stage-name="showStageName ? group.stageName : ''"
:pipeline-id="pipelineId"
+ :css-class-job-name="$options.jobClasses"
/>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 5db2b604956..4db6a3c9fd8 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -218,7 +218,7 @@ export default {
:status="pipeline.detailedStatus"
:time="pipeline.createdAt"
:user="pipeline.user"
- :item-id="Number(pipelineId)"
+ :item-id="pipelineId"
item-name="Pipeline"
>
<gl-button
diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index 7e7f0572faf..fa7330ce890 100644
--- a/app/assets/javascripts/pipelines/components/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
@@ -1,55 +1,18 @@
import { memoize } from 'lodash';
+import { createNodeDict } from '../utils';
import { createSankey } from './dag/drawing_utils';
/*
- The following functions are the main engine in transforming the data as
- received from the endpoint into the format the d3 graph expects.
-
- Input is of the form:
- [nodes]
- nodes: [{category, name, jobs, size}]
- category is the stage name
- name is a group name; in the case that the group has one job, it is
- also the job name
- size is the number of parallel jobs
- jobs: [{ name, needs}]
- job name is either the same as the group name or group x/y
- needs: [job-names]
- needs is an array of job-name strings
-
- Output is of the form:
- { nodes: [node], links: [link] }
- node: { name, category }, + unused info passed through
- link: { source, target, value }, with source & target being node names
- and value being a constant
-
- We create nodes in the GraphQL update function, and then here we create the node dictionary,
- then create links, and then dedupe the links, so that in the case where
- job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link
- from job 1 to job 2 then another from job 2 to job 4.
-
- CREATE LINKS
- nodes.name -> target
- nodes.name.needs.each -> source (source is the name of the group, not the parallel job)
- 10 -> value (constant)
- */
-
-export const createNodeDict = (nodes) => {
- return nodes.reduce((acc, node) => {
- const newNode = {
- ...node,
- needs: node.jobs.map((job) => job.needs || []).flat(),
- };
-
- if (node.size > 1) {
- node.jobs.forEach((job) => {
- acc[job.name] = newNode;
- });
- }
+ A peformant alternative to lodash's isEqual. Because findIndex always finds
+ the first instance of a match, if the found index is not the first, we know
+ it is in fact a duplicate.
+*/
+const deduplicate = (item, itemIndex, arr) => {
+ const foundIdx = arr.findIndex((test) => {
+ return test.source === item.source && test.target === item.target;
+ });
- acc[node.name] = newNode;
- return acc;
- }, {});
+ return foundIdx === itemIndex;
};
export const makeLinksFromNodes = (nodes, nodeDict) => {
@@ -83,7 +46,8 @@ export const getAllAncestors = (nodes, nodeDict) => {
return nodeDict[node]?.needs || '';
})
.flat()
- .filter(Boolean);
+ .filter(Boolean)
+ .filter(deduplicate);
if (needs.length) {
return [...needs, ...getAllAncestors(needs, nodeDict)];
@@ -108,29 +72,15 @@ export const filterByAncestors = (links, nodeDict) =>
const targetNode = target;
const targetNodeNeeds = nodeDict[targetNode].needs;
const targetNodeNeedsMinusSource = targetNodeNeeds.filter((need) => need !== source);
-
const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict);
return !allAncestors.includes(source);
});
-/*
- A peformant alternative to lodash's isEqual. Because findIndex always finds
- the first instance of a match, if the found index is not the first, we know
- it is in fact a duplicate.
-*/
-const deduplicate = (item, itemIndex, arr) => {
- const foundIdx = arr.findIndex((test) => {
- return test.source === item.source && test.target === item.target;
- });
-
- return foundIdx === itemIndex;
-};
-
export const parseData = (nodes) => {
const nodeDict = createNodeDict(nodes);
const allLinks = makeLinksFromNodes(nodes, nodeDict);
- const filteredLinks = filterByAncestors(allLinks, nodeDict);
- const links = filteredLinks.filter(deduplicate);
+ const filteredLinks = allLinks.filter(deduplicate);
+ const links = filterByAncestors(filteredLinks, nodeDict);
return { nodes, links };
};
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
index 40ee071f1f5..3470c963ade 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue
@@ -100,7 +100,7 @@ export default {
<gl-loading-icon v-if="isLoading" size="sm" />
- <gl-dropdown-item v-if="!artifacts.length" data-testid="artifacts-empty-message">
+ <gl-dropdown-item v-if="!artifacts.length && !isLoading" data-testid="artifacts-empty-message">
{{ $options.i18n.emptyArtifactsMessage }}
</gl-dropdown-item>
@@ -110,6 +110,7 @@ export default {
:href="artifact.path"
rel="nofollow"
download
+ class="gl-word-break-word"
data-testid="artifact-item"
>
<gl-sprintf :message="$options.i18n.downloadArtifact">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
index 24b5c85c9d6..3bd149fc782 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stop_modal.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlLink, GlModal } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { __, s__, sprintf } from '~/locale';
@@ -72,7 +71,7 @@ export default {
:action-cancel="cancelProps"
@primary="emitSubmit($event)"
>
- <p v-html="modalText"></p>
+ <p v-html="modalText /* eslint-disable-line vue/no-v-html */"></p>
<p v-if="pipeline">
<ci-icon
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
index 0b70e74b8ff..2dfdaa0ea28 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue
@@ -39,7 +39,7 @@ export default {
return this.value.map((i) => i.type);
},
tokens() {
- const tokens = [
+ return [
{
type: this.$options.userType,
icon: 'user',
@@ -77,20 +77,15 @@ export default {
token: PipelineStatusToken,
operators: OPERATOR_IS_ONLY,
},
- ];
-
- if (gon.features.pipelineSourceFilter) {
- tokens.push({
+ {
type: this.$options.sourceType,
icon: 'trigger-source',
title: s__('Pipeline|Source'),
unique: true,
token: PipelineSourceToken,
operators: OPERATOR_IS_ONLY,
- });
- }
-
- return tokens;
+ },
+ ];
},
parsedParams() {
return map(this.params, (val, key) => ({
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index 2475d958e3c..12ee82f0390 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -212,6 +212,7 @@ export default {
<linked-pipelines-mini-list
v-if="item.triggered.length"
:triggered="item.triggered"
+ :pipeline-path="item.path"
data-testid="mini-graph-downstream"
/>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js
new file mode 100644
index 00000000000..02baa76f627
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js
@@ -0,0 +1,52 @@
+import { s__ } from '~/locale';
+
+export const PIPELINE_SOURCES = [
+ {
+ text: s__('Pipeline|Source|Push'),
+ value: 'push',
+ },
+ {
+ text: s__('Pipeline|Source|Web'),
+ value: 'web',
+ },
+ {
+ text: s__('Pipeline|Source|Trigger'),
+ value: 'trigger',
+ },
+ {
+ text: s__('Pipeline|Source|Schedule'),
+ value: 'schedule',
+ },
+ {
+ text: s__('Pipeline|Source|API'),
+ value: 'api',
+ },
+ {
+ text: s__('Pipeline|Source|External'),
+ value: 'external',
+ },
+ {
+ text: s__('Pipeline|Source|Pipeline'),
+ value: 'pipeline',
+ },
+ {
+ text: s__('Pipeline|Source|Chat'),
+ value: 'chat',
+ },
+ {
+ text: s__('Pipeline|Source|Web IDE'),
+ value: 'webide',
+ },
+ {
+ text: s__('Pipeline|Source|Merge Request'),
+ value: 'merge_request_event',
+ },
+ {
+ text: s__('Pipeline|Source|External Pull Request'),
+ value: 'external_pull_request_event',
+ },
+ {
+ text: s__('Pipeline|Source|Parent Pipeline'),
+ value: 'parent_pipeline',
+ },
+];
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue
index 71efa8b2ab4..9643ddfbd21 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue
@@ -1,8 +1,9 @@
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { PIPELINE_SOURCES } from 'ee_else_ce/pipelines/components/pipelines_list/tokens/constants';
export default {
+ PIPELINE_SOURCES,
components: {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
@@ -18,68 +19,8 @@ export default {
},
},
computed: {
- sources() {
- return [
- {
- text: s__('Pipeline|Source|Push'),
- value: 'push',
- },
- {
- text: s__('Pipeline|Source|Web'),
- value: 'web',
- },
- {
- text: s__('Pipeline|Source|Trigger'),
- value: 'trigger',
- },
- {
- text: s__('Pipeline|Source|Schedule'),
- value: 'schedule',
- },
- {
- text: s__('Pipeline|Source|API'),
- value: 'api',
- },
- {
- text: s__('Pipeline|Source|External'),
- value: 'external',
- },
- {
- text: s__('Pipeline|Source|Pipeline'),
- value: 'pipeline',
- },
- {
- text: s__('Pipeline|Source|Chat'),
- value: 'chat',
- },
- {
- text: s__('Pipeline|Source|Web IDE'),
- value: 'webide',
- },
- {
- text: s__('Pipeline|Source|Merge Request'),
- value: 'merge_request_event',
- },
- {
- text: s__('Pipeline|Source|External Pull Request'),
- value: 'external_pull_request_event',
- },
- {
- text: s__('Pipeline|Source|Parent Pipeline'),
- value: 'parent_pipeline',
- },
- {
- text: s__('Pipeline|Source|On-Demand DAST Scan'),
- value: 'ondemand_dast_scan',
- },
- {
- text: s__('Pipeline|Source|On-Demand DAST Validation'),
- value: 'ondemand_dast_validation',
- },
- ];
- },
- findActiveSource() {
- return this.sources.find((source) => source.value === this.value.data);
+ activeSource() {
+ return PIPELINE_SOURCES.find((source) => source.value === this.value.data);
},
},
};
@@ -89,13 +30,13 @@ export default {
<gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<template #view>
<div class="gl-display-flex gl-align-items-center">
- <span>{{ findActiveSource.text }}</span>
+ <span>{{ activeSource.text }}</span>
</div>
</template>
<template #suggestions>
<gl-filtered-search-suggestion
- v-for="source in sources"
+ v-for="source in $options.PIPELINE_SOURCES"
:key="source.value"
:value="source.value"
>
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index c6e767d5424..ee9560e36c4 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -1,16 +1,10 @@
-import Vue from 'vue';
import createFlash from '~/flash';
-import { parseBoolean } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import Translate from '~/vue_shared/translate';
-import TestReports from './components/test_reports/test_reports.vue';
import createDagApp from './pipeline_details_dag';
import { createPipelinesDetailApp } from './pipeline_details_graph';
import { createPipelineHeaderApp } from './pipeline_details_header';
import { apolloProvider } from './pipeline_shared_client';
-import createTestReportsStore from './stores/test_reports';
-
-Vue.use(Translate);
+import { createTestDetails } from './pipeline_test_details';
const SELECTORS = {
PIPELINE_DETAILS: '.js-pipeline-details-vue',
@@ -19,33 +13,6 @@ const SELECTORS = {
PIPELINE_TESTS: '#js-pipeline-tests-detail',
};
-const createTestDetails = () => {
- const el = document.querySelector(SELECTORS.PIPELINE_TESTS);
- const { blobPath, emptyStateImagePath, hasTestReport, summaryEndpoint, suiteEndpoint } =
- el?.dataset || {};
- const testReportsStore = createTestReportsStore({
- blobPath,
- summaryEndpoint,
- suiteEndpoint,
- });
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- TestReports,
- },
- provide: {
- emptyStateImagePath,
- hasTestReport: parseBoolean(hasTestReport),
- },
- store: testReportsStore,
- render(createElement) {
- return createElement('test-reports');
- },
- });
-};
-
export default async function initPipelineDetailsBundle() {
const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS);
@@ -65,6 +32,27 @@ export default async function initPipelineDetailsBundle() {
});
}
- createDagApp(apolloProvider);
- createTestDetails();
+ try {
+ createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading a section of this page.'),
+ });
+ }
+
+ try {
+ createDagApp(apolloProvider);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading the Needs tab.'),
+ });
+ }
+
+ try {
+ createTestDetails(SELECTORS.PIPELINE_TESTS);
+ } catch {
+ createFlash({
+ message: __('An error occurred while loading the Test Reports tab.'),
+ });
+ }
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js
index 39c3c2ea5c5..9dd5cd7b281 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_graph.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js
@@ -1,6 +1,5 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { GRAPHQL } from './components/graph/constants';
import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue';
import { reportToSentry } from './utils';
@@ -23,7 +22,6 @@ const createPipelinesDetailApp = (
pipelineProjectPath,
pipelineIid,
graphqlResourceEtag,
- dataMethod: GRAPHQL,
},
errorCaptured(err, _vm, info) {
reportToSentry('pipeline_details_graph', `error: ${err}, info: ${info}`);
diff --git a/app/assets/javascripts/pipelines/pipeline_test_details.js b/app/assets/javascripts/pipelines/pipeline_test_details.js
new file mode 100644
index 00000000000..46c7ec07d03
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_test_details.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import Translate from '~/vue_shared/translate';
+import TestReports from './components/test_reports/test_reports.vue';
+import createTestReportsStore from './stores/test_reports';
+
+Vue.use(Translate);
+
+export const createTestDetails = (selector) => {
+ const el = document.querySelector(selector);
+ const { blobPath, emptyStateImagePath, hasTestReport, summaryEndpoint, suiteEndpoint } =
+ el?.dataset || {};
+ const testReportsStore = createTestReportsStore({
+ blobPath,
+ summaryEndpoint,
+ suiteEndpoint,
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ TestReports,
+ },
+ provide: {
+ emptyStateImagePath,
+ hasTestReport: parseBoolean(hasTestReport),
+ },
+ store: testReportsStore,
+ render(createElement) {
+ return createElement('test-reports');
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 02a9e5b7fc6..e28eb74fb1b 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -1,8 +1,58 @@
import * as Sentry from '@sentry/browser';
import { pickBy } from 'lodash';
-import { createNodeDict } from './components/parsing_utils';
import { SUPPORTED_FILTER_PARAMETERS } from './constants';
+/*
+ The following functions are the main engine in transforming the data as
+ received from the endpoint into the format the d3 graph expects.
+
+ Input is of the form:
+ [nodes]
+ nodes: [{category, name, jobs, size}]
+ category is the stage name
+ name is a group name; in the case that the group has one job, it is
+ also the job name
+ size is the number of parallel jobs
+ jobs: [{ name, needs}]
+ job name is either the same as the group name or group x/y
+ needs: [job-names]
+ needs is an array of job-name strings
+
+ Output is of the form:
+ { nodes: [node], links: [link] }
+ node: { name, category }, + unused info passed through
+ link: { source, target, value }, with source & target being node names
+ and value being a constant
+
+ We create nodes in the GraphQL update function, and then here we create the node dictionary,
+ then create links, and then dedupe the links, so that in the case where
+ job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link
+ from job 1 to job 2 then another from job 2 to job 4.
+
+ CREATE LINKS
+ nodes.name -> target
+ nodes.name.needs.each -> source (source is the name of the group, not the parallel job)
+ 10 -> value (constant)
+ */
+
+export const createNodeDict = (nodes) => {
+ return nodes.reduce((acc, node) => {
+ const newNode = {
+ ...node,
+ needs: node.jobs.map((job) => job.needs || []).flat(),
+ };
+
+ if (node.size > 1) {
+ node.jobs.forEach((job) => {
+ acc[job.name] = newNode;
+ });
+ }
+
+ acc[node.name] = newNode;
+ return acc;
+ }, {});
+};
+
export const validateParams = (params) => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
};
diff --git a/app/assets/javascripts/popovers/components/popovers.vue b/app/assets/javascripts/popovers/components/popovers.vue
index 05a209a97ad..a758503b56b 100644
--- a/app/assets/javascripts/popovers/components/popovers.vue
+++ b/app/assets/javascripts/popovers/components/popovers.vue
@@ -1,11 +1,5 @@
<script>
-// We can't use v-safe-html here as the popover's title or content might contains SVGs that would
-// be stripped by the directive's sanitizer. Instead, we fallback on v-html and we use GitLab's
-// dompurify config that lets SVGs be rendered properly.
-// Context: https://gitlab.com/gitlab-org/gitlab/-/issues/247207
-/* eslint-disable vue/no-v-html */
-import { GlPopover } from '@gitlab/ui';
-import { sanitize } from '~/lib/dompurify';
+import { GlPopover, GlSafeHtmlDirective } from '@gitlab/ui';
const newPopover = (element) => {
const { content, html, placement, title, triggers = 'focus' } = element.dataset;
@@ -24,6 +18,9 @@ export default {
components: {
GlPopover,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
data() {
return {
popovers: [],
@@ -71,9 +68,9 @@ export default {
popoverExists(element) {
return this.popovers.some((popover) => popover.target === element);
},
- getSafeHtml(html) {
- return sanitize(html);
- },
+ },
+ safeHtmlConfig: {
+ ADD_TAGS: ['use'], // to support icon SVGs
},
};
</script>
@@ -82,10 +79,10 @@ export default {
<div>
<gl-popover v-for="(popover, index) in popovers" :key="index" v-bind="popover">
<template #title>
- <span v-if="popover.html" v-html="getSafeHtml(popover.title)"></span>
+ <span v-if="popover.html" v-safe-html:[$options.safeHtmlConfig]="popover.title"></span>
<span v-else>{{ popover.title }}</span>
</template>
- <span v-if="popover.html" v-html="getSafeHtml(popover.content)"></span>
+ <span v-if="popover.html" v-safe-html:[$options.safeHtmlConfig]="popover.content"></span>
<span v-else>{{ popover.content }}</span>
</gl-popover>
</div>
diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js
index 4b14df21f05..fd45d643ecc 100644
--- a/app/assets/javascripts/project_select_combo_button.js
+++ b/app/assets/javascripts/project_select_combo_button.js
@@ -30,7 +30,7 @@ export default class ProjectSelectComboButton {
}
initLocalStorage() {
- const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
+ const localStorageIsSafe = AccessorUtilities.canUseLocalStorage();
if (localStorageIsSafe) {
this.localStorageKey = [
diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
index a4a1cb5584d..da14b1e8470 100644
--- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
+++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue
@@ -87,6 +87,7 @@ export default {
<linked-pipelines-mini-list
v-if="hasDownstream"
:triggered="downstreamPipelines"
+ :pipeline-path="pipeline.path"
data-testid="commit-box-mini-graph-downstream"
/>
</div>
diff --git a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
index f7e930bb3f2..ee18c70b6fd 100644
--- a/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
+++ b/app/assets/javascripts/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql
@@ -1,6 +1,7 @@
query getLinkedPipelines($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) {
pipeline(iid: $iid) {
+ path
downstream {
nodes {
id
diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
index 1d4ec4c110b..2505c47147f 100644
--- a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
+++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js
@@ -5,7 +5,12 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
});
export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => {
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
index d3cadcd2bd5..ecd2288eb2f 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
@@ -199,6 +199,16 @@ export default {
},
];
},
+ chartOptions() {
+ return {
+ ...this.$options.timesChartOptions,
+ yAxis: {
+ axisLabel: {
+ formatter: (value) => value,
+ },
+ },
+ };
+ },
},
methods: {
hideAlert() {
@@ -314,7 +324,7 @@ export default {
<strong>{{ __('Pipeline durations for the last 30 commits') }}</strong>
<gl-column-chart
:height="$options.chartContainerHeight"
- :option="$options.timesChartOptions"
+ :option="chartOptions"
:bars="timesChartTransformedData"
:y-axis-title="__('Minutes')"
:x-axis-title="__('Commit')"
diff --git a/app/assets/javascripts/projects/pipelines/charts/index.js b/app/assets/javascripts/projects/pipelines/charts/index.js
index 5f5ee44c204..f7ea89068a0 100644
--- a/app/assets/javascripts/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/projects/pipelines/charts/index.js
@@ -7,7 +7,7 @@ import ProjectPipelinesCharts from './components/app.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
});
const mountPipelineChartsApp = (el) => {
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index ee02f446795..ebd20583a1c 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -71,6 +71,17 @@ const deriveProjectPathFromUrl = ($projectImportUrl) => {
}
};
+const bindHowToImport = () => {
+ $('.how_to_import_link').on('click', (e) => {
+ e.preventDefault();
+ $(e.currentTarget).next('.modal').show();
+ });
+
+ $('.modal-header .close').on('click', () => {
+ $('.modal').hide();
+ });
+};
+
const bindEvents = () => {
const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url');
@@ -88,14 +99,7 @@ const bindEvents = () => {
return;
}
- $('.how_to_import_link').on('click', (e) => {
- e.preventDefault();
- $(e.currentTarget).next('.modal').show();
- });
-
- $('.modal-header .close').on('click', () => {
- $('.modal').hide();
- });
+ bindHowToImport();
$('.btn_import_gitlab_project').on('click', () => {
const importHref = $('a.btn_import_gitlab_project').attr('href');
@@ -174,3 +178,5 @@ export default {
onProjectNameChange,
onProjectPathChange,
};
+
+export { bindHowToImport };
diff --git a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
index e4edb950a1e..91d8fca0487 100644
--- a/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
+++ b/app/assets/javascripts/projects/settings/components/shared_runners_toggle.vue
@@ -43,6 +43,7 @@ export default {
isSharedRunnerEnabled: this.isEnabled,
errorMessage: null,
successfulValidation: false,
+ ccAlertDismissed: false,
};
},
computed: {
@@ -50,7 +51,8 @@ export default {
return (
this.isCreditCardValidationRequired &&
!this.isSharedRunnerEnabled &&
- !this.successfulValidation
+ !this.successfulValidation &&
+ !this.ccAlertDismissed
);
},
},
@@ -89,6 +91,7 @@ export default {
class="gl-pb-5"
:custom-message="$options.i18n.REQUIRES_VALIDATION_TEXT"
@verifiedCreditCard="creditCardValidated"
+ @dismiss="ccAlertDismissed = true"
/>
<gl-toggle
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 34d53e2de0c..fe2d376f1da 100644
--- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
+++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue
@@ -1,5 +1,13 @@
<script>
-import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import {
+ GlButton,
+ GlFormSelect,
+ GlToggle,
+ GlLoadingIcon,
+ GlSprintf,
+ GlFormInput,
+ GlLink,
+} from '@gitlab/ui';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -14,6 +22,8 @@ export default {
GlToggle,
GlLoadingIcon,
GlSprintf,
+ GlFormInput,
+ GlLink,
},
props: {
isEnabled: {
@@ -148,17 +158,37 @@ export default {
<span class="sr-only">{{ __('Fetching incoming email') }}</span>
</template>
- <template v-if="hasProjectKeySupport">
- <label for="service-desk-project-suffix" class="mt-3">
- {{ __('Project name suffix') }}
- </label>
- <input id="service-desk-project-suffix" v-model.trim="projectKey" class="form-control" />
- <span class="form-text text-muted">
- {{
- __('A string appended to the project path to form the Service Desk email address.')
- }}
- </span>
- </template>
+ <label for="service-desk-project-suffix" class="mt-3">
+ {{ __('Project name suffix') }}
+ </label>
+ <gl-form-input
+ v-if="hasProjectKeySupport"
+ id="service-desk-project-suffix"
+ v-model.trim="projectKey"
+ data-testid="project-suffix"
+ class="form-control"
+ />
+ <span v-if="hasProjectKeySupport" class="form-text text-muted">
+ {{ __('A string appended to the project path to form the Service Desk email address.') }}
+ </span>
+ <span v-else class="form-text text-muted">
+ <gl-sprintf
+ :message="
+ __(
+ 'To add a custom suffix, set up a Service Desk email address. %{linkStart}Learn more.%{linkEnd}',
+ )
+ "
+ >
+ <template #link="{ content }">
+ <gl-link
+ href="https://docs.gitlab.com/ee/user/project/service_desk.html#using-a-custom-email-address"
+ target="_blank"
+ class="gl-text-blue-600 font-size-inherit"
+ >{{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
<label for="service-desk-template-select" class="mt-3">
{{ __('Template to append to all Service Desk issues') }}
diff --git a/app/assets/javascripts/projects/storage_counter/components/app.vue b/app/assets/javascripts/projects/storage_counter/components/app.vue
new file mode 100644
index 00000000000..1a911ea3d9b
--- /dev/null
+++ b/app/assets/javascripts/projects/storage_counter/components/app.vue
@@ -0,0 +1,106 @@
+<script>
+import { GlAlert, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue';
+import {
+ ERROR_MESSAGE,
+ LEARN_MORE_LABEL,
+ USAGE_QUOTAS_LABEL,
+ TOTAL_USAGE_TITLE,
+ TOTAL_USAGE_SUBTITLE,
+ TOTAL_USAGE_DEFAULT_TEXT,
+ HELP_LINK_ARIA_LABEL,
+} from '../constants';
+import getProjectStorageCount from '../queries/project_storage.query.graphql';
+import { parseGetProjectStorageResults } from '../utils';
+import StorageTable from './storage_table.vue';
+
+export default {
+ name: 'StorageCounterApp',
+ components: {
+ GlAlert,
+ GlLink,
+ GlLoadingIcon,
+ StorageTable,
+ UsageGraph,
+ },
+ inject: ['projectPath', 'helpLinks'],
+ apollo: {
+ project: {
+ query: getProjectStorageCount,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ };
+ },
+ update(data) {
+ return parseGetProjectStorageResults(data, this.helpLinks);
+ },
+ error() {
+ this.error = ERROR_MESSAGE;
+ },
+ },
+ },
+ data() {
+ return {
+ project: {},
+ error: '',
+ };
+ },
+ computed: {
+ totalUsage() {
+ return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT;
+ },
+ storageTypes() {
+ return this.project?.storage?.storageTypes || [];
+ },
+ },
+ methods: {
+ clearError() {
+ this.error = '';
+ },
+ helpLinkAriaLabel(linkTitle) {
+ return sprintf(HELP_LINK_ARIA_LABEL, {
+ linkTitle,
+ });
+ },
+ },
+ LEARN_MORE_LABEL,
+ USAGE_QUOTAS_LABEL,
+ TOTAL_USAGE_TITLE,
+ TOTAL_USAGE_SUBTITLE,
+};
+</script>
+<template>
+ <gl-loading-icon v-if="$apollo.queries.project.loading" class="gl-mt-5" size="md" />
+ <gl-alert v-else-if="error" variant="danger" @dismiss="clearError">
+ {{ error }}
+ </gl-alert>
+ <div v-else>
+ <div class="gl-pt-5 gl-px-3">
+ <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
+ <div>
+ <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p>
+ <p class="gl-m-0 gl-text-gray-400">
+ {{ $options.TOTAL_USAGE_SUBTITLE }}
+ <gl-link
+ :href="helpLinks.usageQuotasHelpPagePath"
+ target="_blank"
+ :aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)"
+ data-testid="usage-quotas-help-link"
+ >
+ {{ $options.LEARN_MORE_LABEL }}
+ </gl-link>
+ </p>
+ </div>
+ <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage">
+ {{ totalUsage }}
+ </p>
+ </div>
+ </div>
+ <div v-if="project.statistics" class="gl-w-full">
+ <usage-graph :root-storage-statistics="project.statistics" :limit="0" />
+ </div>
+ <storage-table :storage-types="storageTypes" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/projects/storage_counter/components/storage_table.vue b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue
new file mode 100644
index 00000000000..7047fd925fb
--- /dev/null
+++ b/app/assets/javascripts/projects/storage_counter/components/storage_table.vue
@@ -0,0 +1,78 @@
+<script>
+import { GlLink, GlIcon, GlTable, GlSprintf } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { thWidthClass } from '~/lib/utils/table_utility';
+import { sprintf } from '~/locale';
+import { PROJECT_TABLE_LABELS, HELP_LINK_ARIA_LABEL } from '../constants';
+
+export default {
+ name: 'StorageTable',
+ components: {
+ GlLink,
+ GlIcon,
+ GlTable,
+ GlSprintf,
+ },
+ props: {
+ storageTypes: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ helpLinkAriaLabel(linkTitle) {
+ return sprintf(HELP_LINK_ARIA_LABEL, {
+ linkTitle,
+ });
+ },
+ },
+ projectTableFields: [
+ {
+ key: 'storageType',
+ label: PROJECT_TABLE_LABELS.STORAGE_TYPE,
+ thClass: thWidthClass(90),
+ sortable: true,
+ },
+ {
+ key: 'value',
+ label: PROJECT_TABLE_LABELS.VALUE,
+ thClass: thWidthClass(10),
+ sortable: true,
+ formatter: (value) => {
+ return numberToHumanSize(value, 1);
+ },
+ },
+ ],
+};
+</script>
+<template>
+ <gl-table :items="storageTypes" :fields="$options.projectTableFields">
+ <template #cell(storageType)="{ item }">
+ <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`">
+ {{ item.storageType.name }}
+ <gl-link
+ v-if="item.storageType.helpPath"
+ :href="item.storageType.helpPath"
+ target="_blank"
+ :aria-label="helpLinkAriaLabel(item.storageType.name)"
+ :data-testid="`${item.storageType.id}-help-link`"
+ >
+ <gl-icon name="question" :size="12" />
+ </gl-link>
+ </p>
+ <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
+ {{ item.storageType.description }}
+ </p>
+ <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm">
+ <gl-icon name="warning" :size="12" />
+ <gl-sprintf :message="item.storageType.warningMessage">
+ <template #warningLink="{ content }">
+ <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-table>
+</template>
diff --git a/app/assets/javascripts/projects/storage_counter/constants.js b/app/assets/javascripts/projects/storage_counter/constants.js
new file mode 100644
index 00000000000..d9b28abfbe7
--- /dev/null
+++ b/app/assets/javascripts/projects/storage_counter/constants.js
@@ -0,0 +1,61 @@
+import { s__, __ } from '~/locale';
+
+export const PROJECT_STORAGE_TYPES = [
+ {
+ id: 'buildArtifactsSize',
+ name: s__('UsageQuota|Artifacts'),
+ description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'),
+ warningMessage: s__(
+ 'UsageQuota|There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.',
+ ),
+ warningLink: 'https://gitlab.com/groups/gitlab-org/-/epics/5380',
+ },
+ {
+ id: 'lfsObjectsSize',
+ name: s__('UsageQuota|LFS Storage'),
+ description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
+ },
+ {
+ id: 'packagesSize',
+ name: s__('UsageQuota|Packages'),
+ description: s__('UsageQuota|Code packages and container images.'),
+ },
+ {
+ id: 'repositorySize',
+ name: s__('UsageQuota|Repository'),
+ description: s__('UsageQuota|Git repository, managed by the Gitaly service.'),
+ },
+ {
+ id: 'snippetsSize',
+ name: s__('UsageQuota|Snippets'),
+ description: s__('UsageQuota|Shared bits of code and text.'),
+ },
+ {
+ id: 'uploadsSize',
+ name: s__('UsageQuota|Uploads'),
+ description: s__('UsageQuota|File attachments and smaller design graphics.'),
+ },
+ {
+ id: 'wikiSize',
+ name: s__('UsageQuota|Wiki'),
+ description: s__('UsageQuota|Wiki content.'),
+ },
+];
+
+export const PROJECT_TABLE_LABELS = {
+ STORAGE_TYPE: s__('UsageQuota|Storage type'),
+ VALUE: s__('UsageQuota|Usage'),
+};
+
+export const ERROR_MESSAGE = s__(
+ 'UsageQuota|Something went wrong while fetching project storage statistics',
+);
+
+export const LEARN_MORE_LABEL = s__('Learn more.');
+export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas');
+export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
+export const TOTAL_USAGE_DEFAULT_TEXT = __('N/A');
+export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage Breakdown');
+export const TOTAL_USAGE_SUBTITLE = s__(
+ 'UsageQuota|Includes project registry, artifacts, packages, wiki, uploads and other items.',
+);
diff --git a/app/assets/javascripts/projects/storage_counter/index.js b/app/assets/javascripts/projects/storage_counter/index.js
new file mode 100644
index 00000000000..10668f08402
--- /dev/null
+++ b/app/assets/javascripts/projects/storage_counter/index.js
@@ -0,0 +1,51 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import StorageCounterApp from './components/app.vue';
+
+Vue.use(VueApollo);
+
+export default (containerId = 'js-project-storage-count-app') => {
+ const el = document.getElementById(containerId);
+
+ if (!el) {
+ return false;
+ }
+
+ const {
+ projectPath,
+ usageQuotasHelpPagePath,
+ buildArtifactsHelpPagePath,
+ lfsObjectsHelpPagePath,
+ packagesHelpPagePath,
+ repositoryHelpPagePath,
+ snippetsHelpPagePath,
+ uploadsHelpPagePath,
+ wikiHelpPagePath,
+ } = el.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: {
+ projectPath,
+ helpLinks: {
+ usageQuotasHelpPagePath,
+ buildArtifactsHelpPagePath,
+ lfsObjectsHelpPagePath,
+ packagesHelpPagePath,
+ repositoryHelpPagePath,
+ snippetsHelpPagePath,
+ uploadsHelpPagePath,
+ wikiHelpPagePath,
+ },
+ },
+ render(createElement) {
+ return createElement(StorageCounterApp);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql b/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql
new file mode 100644
index 00000000000..a4f2c529522
--- /dev/null
+++ b/app/assets/javascripts/projects/storage_counter/queries/project_storage.query.graphql
@@ -0,0 +1,16 @@
+query getProjectStorageCount($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ id
+ statistics {
+ buildArtifactsSize
+ pipelineArtifactsSize
+ lfsObjectsSize
+ packagesSize
+ repositorySize
+ snippetsSize
+ storageSize
+ uploadsSize
+ wikiSize
+ }
+ }
+}
diff --git a/app/assets/javascripts/projects/storage_counter/utils.js b/app/assets/javascripts/projects/storage_counter/utils.js
new file mode 100644
index 00000000000..cb26603fff5
--- /dev/null
+++ b/app/assets/javascripts/projects/storage_counter/utils.js
@@ -0,0 +1,40 @@
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { PROJECT_STORAGE_TYPES } from './constants';
+
+/**
+ * This method parses the results from `getProjectStorageCount` call.
+ *
+ * @param {Object} data graphql result
+ * @returns {Object}
+ */
+export const parseGetProjectStorageResults = (data, helpLinks) => {
+ const projectStatistics = data?.project?.statistics;
+ if (!projectStatistics) {
+ return {};
+ }
+ const { storageSize, ...storageStatistics } = projectStatistics;
+ const storageTypes = PROJECT_STORAGE_TYPES.reduce((types, currentType) => {
+ if (!storageStatistics[currentType.id]) {
+ return types;
+ }
+
+ const helpPathKey = currentType.id.replace(`Size`, `HelpPagePath`);
+ const helpPath = helpLinks[helpPathKey];
+
+ return types.concat({
+ storageType: {
+ ...currentType,
+ helpPath,
+ },
+ value: storageStatistics[currentType.id],
+ });
+ }, []);
+
+ return {
+ storage: {
+ totalUsage: numberToHumanSize(storageSize, 1),
+ storageTypes,
+ },
+ statistics: projectStatistics,
+ };
+};
diff --git a/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue
index 02e31d6fbb3..668cc10c454 100644
--- a/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue
+++ b/app/assets/javascripts/projects/terraform_notification/components/terraform_notification.vue
@@ -1,8 +1,12 @@
<script>
import { GlBanner } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
-import { setCookie } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue';
+import { EVENT_LABEL, DISMISS_EVENT, CLICK_EVENT } from '../constants';
+
+const trackingMixin = Tracking.mixin({ label: EVENT_LABEL });
export default {
name: 'TerraformNotification',
@@ -15,37 +19,42 @@ export default {
},
components: {
GlBanner,
+ UserCalloutDismisser,
},
- inject: ['terraformImagePath', 'bannerDismissedKey'],
- data() {
- return {
- isVisible: true,
- };
- },
+ mixins: [trackingMixin],
+ inject: ['terraformImagePath'],
computed: {
docsUrl() {
- return helpPagePath('user/infrastructure/terraform_state');
+ return helpPagePath('user/infrastructure/iac/terraform_state.md');
},
},
methods: {
handleClose() {
- setCookie(this.bannerDismissedKey, true);
- this.isVisible = false;
+ this.track(DISMISS_EVENT);
+ this.$refs.calloutDismisser.dismiss();
+ },
+ buttonClick() {
+ this.track(CLICK_EVENT);
},
},
};
</script>
<template>
- <div v-if="isVisible" class="gl-py-5">
- <gl-banner
- :title="$options.i18n.title"
- :button-text="$options.i18n.buttonText"
- :button-link="docsUrl"
- :svg-path="terraformImagePath"
- variant="promotion"
- @close="handleClose"
- >
- <p>{{ $options.i18n.description }}</p>
- </gl-banner>
- </div>
+ <user-callout-dismisser ref="calloutDismisser" feature-name="terraform_notification_dismissed">
+ <template #default="{ shouldShowCallout }">
+ <div v-if="shouldShowCallout" class="gl-py-5">
+ <gl-banner
+ :title="$options.i18n.title"
+ :button-text="$options.i18n.buttonText"
+ :button-link="docsUrl"
+ :svg-path="terraformImagePath"
+ variant="promotion"
+ @primary="buttonClick"
+ @close="handleClose"
+ >
+ <p>{{ $options.i18n.description }}</p>
+ </gl-banner>
+ </div>
+ </template>
+ </user-callout-dismisser>
</template>
diff --git a/app/assets/javascripts/projects/terraform_notification/constants.js b/app/assets/javascripts/projects/terraform_notification/constants.js
new file mode 100644
index 00000000000..029f40b2ab2
--- /dev/null
+++ b/app/assets/javascripts/projects/terraform_notification/constants.js
@@ -0,0 +1,3 @@
+export const EVENT_LABEL = 'terraform_banner';
+export const DISMISS_EVENT = 'dismiss_banner';
+export const CLICK_EVENT = 'click_button';
diff --git a/app/assets/javascripts/projects/terraform_notification/index.js b/app/assets/javascripts/projects/terraform_notification/index.js
index 0a273247930..362e71ed902 100644
--- a/app/assets/javascripts/projects/terraform_notification/index.js
+++ b/app/assets/javascripts/projects/terraform_notification/index.js
@@ -1,12 +1,18 @@
import Vue from 'vue';
-import { parseBoolean, getCookie } from '~/lib/utils/common_utils';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import TerraformNotification from './components/terraform_notification.vue';
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
export default () => {
const el = document.querySelector('.js-terraform-notification');
- const bannerDismissedKey = 'terraform_notification_dismissed';
- if (!el || parseBoolean(getCookie(bannerDismissedKey))) {
+ if (!el) {
return false;
}
@@ -14,9 +20,9 @@ export default () => {
return new Vue({
el,
+ apolloProvider,
provide: {
terraformImagePath,
- bannerDismissedKey,
},
render: (createElement) => createElement(TerraformNotification),
});
diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
index c1dae75801e..eecb3573046 100644
--- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
+++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlButton, GlFormGroup, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
@@ -85,7 +84,7 @@ export default {
</p>
</div>
<div class="col-lg-9">
- <p v-html="sectionDescription"></p>
+ <p v-html="sectionDescription /* eslint-disable-line vue/no-v-html */"></p>
<gl-form-group :label="__('URL')" label-for="notify-url" label-class="label-bold">
<div class="input-group">
<gl-form-input id="notify-url" :readonly="true" :value="notifyUrl" />
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index d0d2c1400a7..d4b52860261 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -12,7 +12,7 @@ export default class ProtectedBranchCreate {
this.hasLicense = options.hasLicense;
this.$form = $('.js-new-protected-branch');
- this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
+ this.isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage();
this.currentProjectUserDefaults = {};
this.buildDropdowns();
this.$forcePushToggle = this.$form.find('.js-force-push-toggle');
diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue
index 82963fe98fd..ce781c64006 100644
--- a/app/assets/javascripts/ref/components/ref_selector.vue
+++ b/app/assets/javascripts/ref/components/ref_selector.vue
@@ -149,8 +149,7 @@ export default {
// This method is defined here instead of in `methods`
// because we need to access the .cancel() method
// lodash attaches to the function, which is
- // made inaccessible by Vue. More info:
- // https://stackoverflow.com/a/52988020/1063392
+ // made inaccessible by Vue.
this.debouncedSearch = debounce(function search() {
this.search();
}, SEARCH_DEBOUNCE_MS);
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index 68bca2fc6b9..3201ca1f443 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import $ from 'jquery';
import { isEmpty } from 'lodash';
import { scrollToElement } from '~/lib/utils/common_utils';
@@ -103,7 +102,10 @@ export default {
<evidence-block v-if="hasEvidence" :release="release" />
<div ref="gfm-content" class="card-text gl-mt-3">
- <div class="md" v-html="release.descriptionHtml"></div>
+ <div
+ class="md"
+ v-html="release.descriptionHtml /* eslint-disable-line vue/no-v-html */"
+ ></div>
</div>
</div>
diff --git a/app/assets/javascripts/reports/components/issue_body.js b/app/assets/javascripts/reports/components/issue_body.js
index 6014d9d6ad8..04e72809e62 100644
--- a/app/assets/javascripts/reports/components/issue_body.js
+++ b/app/assets/javascripts/reports/components/issue_body.js
@@ -1,18 +1,16 @@
import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
-import AccessibilityIssueBody from '../accessibility_report/components/accessibility_issue_body.vue';
-import CodequalityIssueBody from '../codequality_report/components/codequality_issue_body.vue';
-import TestIssueBody from '../grouped_test_report/components/test_issue_body.vue';
export const components = {
- AccessibilityIssueBody,
- CodequalityIssueBody,
- TestIssueBody,
+ AccessibilityIssueBody: () =>
+ import('../accessibility_report/components/accessibility_issue_body.vue'),
+ CodequalityIssueBody: () => import('../codequality_report/components/codequality_issue_body.vue'),
+ TestIssueBody: () => import('../grouped_test_report/components/test_issue_body.vue'),
};
export const componentNames = {
- AccessibilityIssueBody: AccessibilityIssueBody.name,
- CodequalityIssueBody: CodequalityIssueBody.name,
- TestIssueBody: TestIssueBody.name,
+ AccessibilityIssueBody: 'AccessibilityIssueBody',
+ CodequalityIssueBody: 'CodequalityIssueBody',
+ TestIssueBody: 'TestIssueBody',
};
export const iconComponents = {
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index 8871da8fbd7..918263bfb5c 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -53,11 +53,7 @@ export default {
};
</script>
<template>
- <li
- :class="{ 'is-dismissed': issue.isDismissed }"
- class="report-block-list-issue align-items-center"
- data-qa-selector="report_item_row"
- >
+ <li class="report-block-list-issue align-items-center" data-qa-selector="report_item_row">
<component
:is="iconComponent"
v-if="showReportSectionStatusIcon"
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 665b0698cc0..1d79818cbe8 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -118,7 +118,7 @@ export default {
return this.$apollo.queries.project.loading || this.isLoadingLegacyViewer;
},
isBinaryFileType() {
- return this.isBinary || this.viewer.fileType === 'download';
+ return this.isBinary || this.blobInfo.simpleViewer?.fileType !== 'text';
},
blobInfo() {
const nodes = this.project?.repository?.blobs?.nodes || [];
@@ -180,7 +180,7 @@ export default {
<div v-if="blobInfo && !isLoading" class="file-holder">
<blob-header
:blob="blobInfo"
- :hide-viewer-switcher="!hasRichViewer || isBinary"
+ :hide-viewer-switcher="!hasRichViewer || isBinaryFileType"
:is-binary="isBinaryFileType"
:active-viewer-type="viewer.type"
:has-render-error="hasRenderError"
@@ -188,7 +188,7 @@ export default {
>
<template #actions>
<blob-edit
- :show-edit-button="!isBinary"
+ :show-edit-button="!isBinaryFileType"
:edit-path="blobInfo.editBlobPath"
:web-ide-path="blobInfo.ideEditPath"
/>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
new file mode 100644
index 00000000000..83d36209bb3
--- /dev/null
+++ b/app/assets/javascripts/repository/components/blob_viewers/image_viewer.vue
@@ -0,0 +1,19 @@
+<script>
+export default {
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ alt: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-text-center gl-p-7 gl-bg-gray-50">
+ <img :src="url" :alt="alt" data-testid="image" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/blob_viewers/index.js b/app/assets/javascripts/repository/components/blob_viewers/index.js
index 4e16b16041f..3b4f4eb51fe 100644
--- a/app/assets/javascripts/repository/components/blob_viewers/index.js
+++ b/app/assets/javascripts/repository/components/blob_viewers/index.js
@@ -6,6 +6,8 @@ export const loadViewer = (type) => {
return () => import(/* webpackChunkName: 'blob_text_viewer' */ './text_viewer.vue');
case 'download':
return () => import(/* webpackChunkName: 'blob_download_viewer' */ './download_viewer.vue');
+ case 'image':
+ return () => import(/* webpackChunkName: 'blob_image_viewer' */ './image_viewer.vue');
default:
return null;
}
@@ -23,5 +25,9 @@ export const viewerProps = (type, blob) => {
filePath: blob.rawPath,
fileSize: blob.rawSize,
},
+ image: {
+ url: blob.rawPath,
+ alt: blob.name,
+ },
}[type];
};
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index a7176853819..5c713796bd6 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
@@ -125,7 +124,7 @@ export default {
:href="commit.webPath"
:class="{ 'font-italic': !commit.message }"
class="commit-row-message item-title"
- v-html="commit.titleHtml"
+ v-html="commit.titleHtml /* eslint-disable-line vue/no-v-html */"
/>
<gl-button
v-if="commit.descriptionHtml"
@@ -153,11 +152,14 @@ export default {
v-if="commitDescription"
:class="{ 'd-block': showDescription }"
class="commit-row-description gl-mb-3"
- v-html="commitDescription"
+ v-html="commitDescription /* eslint-disable-line vue/no-v-html */"
></pre>
</div>
<div class="commit-actions flex-row">
- <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div>
+ <div
+ v-if="commit.signatureHtml"
+ v-html="commit.signatureHtml /* eslint-disable-line vue/no-v-html */"
+ ></div>
<div v-if="commit.pipeline" class="ci-status-link">
<gl-link
v-gl-tooltip.left
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index b74c2333148..54e67c5ab5c 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
@@ -60,7 +59,11 @@ export default {
</div>
<div class="blob-viewer" data-qa-selector="blob_viewer_content" itemprop="about">
<gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" />
- <div v-else-if="readme" ref="readme" v-html="readme.html"></div>
+ <div
+ v-else-if="readme"
+ ref="readme"
+ v-html="readme.html /* eslint-disable-line vue/no-v-html */"
+ ></div>
</div>
</article>
</template>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 69eefc807d7..10a30bd44b1 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -100,9 +100,9 @@ export default {
/>
<template v-for="val in entries">
<table-row
- v-for="entry in val"
+ v-for="(entry, index) in val"
:id="entry.id"
- :key="`${entry.flatPath}-${entry.id}`"
+ :key="`${entry.flatPath}-${entry.id}-${index}`"
:sha="entry.sha"
:project-path="projectPath"
:current-path="path"
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index fa358a75cc1..009dd19b4a5 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import {
GlBadge,
GlLink,
@@ -11,6 +10,7 @@ import {
} from '@gitlab/ui';
import { escapeRegExp } from 'lodash';
import filesQuery from 'shared_queries/repository/files.query.graphql';
+import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import { escapeFileUrl } from '~/lib/utils/url_utility';
import { TREE_PAGE_SIZE } from '~/repository/constants';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -154,7 +154,8 @@ export default {
return this.isFolder ? this.loadFolder() : this.loadBlob();
},
loadFolder() {
- this.apolloQuery(filesQuery, {
+ const query = this.glFeatures.paginatedTreeGraphqlQuery ? paginatedTreeQuery : filesQuery;
+ this.apolloQuery(query, {
projectPath: this.projectPath,
ref: this.ref,
path: this.path,
@@ -230,7 +231,7 @@ export default {
:href="commit.commitPath"
:title="commit.message"
class="str-truncated-100 tree-commit-link"
- v-html="commit.titleHtml"
+ v-html="commit.titleHtml /* eslint-disable-line vue/no-v-html */"
/>
<gl-skeleton-loading v-else :lines="1" class="h-auto" />
</td>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index c861fb8dd06..5a8ead9ae8f 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -1,5 +1,6 @@
<script>
import filesQuery from 'shared_queries/repository/files.query.graphql';
+import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import createFlash from '~/flash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '../../locale';
@@ -69,6 +70,9 @@ export default {
hasShowMore() {
return !this.clickedShowMore && this.pageLimitReached;
},
+ paginatedTreeEnabled() {
+ return this.glFeatures.paginatedTreeGraphqlQuery;
+ },
},
watch: {
@@ -91,7 +95,7 @@ export default {
return this.$apollo
.query({
- query: filesQuery,
+ query: this.paginatedTreeEnabled ? paginatedTreeQuery : filesQuery,
variables: {
projectPath: this.projectPath,
ref: this.ref,
@@ -104,13 +108,20 @@ export default {
if (data.errors) throw data.errors;
if (!data?.project?.repository || originalPath !== (this.path || '/')) return;
- const pageInfo = this.hasNextPage(data.project.repository.tree);
+ const pageInfo = this.paginatedTreeEnabled
+ ? data.project.repository.paginatedTree.pageInfo
+ : this.hasNextPage(data.project.repository.tree);
this.isLoadingFiles = false;
this.entries = Object.keys(this.entries).reduce(
(acc, key) => ({
...acc,
- [key]: this.normalizeData(key, data.project.repository.tree[key].edges),
+ [key]: this.normalizeData(
+ key,
+ this.paginatedTreeEnabled
+ ? data.project.repository.paginatedTree.nodes[0][key]
+ : data.project.repository.tree[key].edges,
+ ),
}),
{},
);
@@ -132,7 +143,9 @@ export default {
});
},
normalizeData(key, data) {
- return this.entries[key].concat(data.map(({ node }) => node));
+ return this.entries[key].concat(
+ this.paginatedTreeEnabled ? data.nodes : data.map(({ node }) => node),
+ );
},
hasNextPage(data) {
return []
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index b536bcb1875..93032bf17e2 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -11,3 +11,5 @@ export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these c
export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52;
export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72;
+
+export const LIMITED_CONTAINER_WIDTH_CLASS = 'limit-container-width';
diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js
index ffc260ec84f..a2ddcbf0e4c 100644
--- a/app/assets/javascripts/repository/mixins/preload.js
+++ b/app/assets/javascripts/repository/mixins/preload.js
@@ -1,4 +1,5 @@
import filesQuery from 'shared_queries/repository/files.query.graphql';
+import paginatedTreeQuery from 'shared_queries/repository/paginated_tree.query.graphql';
import projectPathQuery from '../queries/project_path.query.graphql';
import getRefMixin from './get_ref';
@@ -21,7 +22,7 @@ export default {
return this.$apollo
.query({
- query: filesQuery,
+ query: gon.features.paginatedTreeGraphqlQuery ? paginatedTreeQuery : filesQuery,
variables: {
projectPath: this.projectPath,
ref: this.ref,
diff --git a/app/assets/javascripts/repository/pages/blob.vue b/app/assets/javascripts/repository/pages/blob.vue
index 2645b294096..c09e2133936 100644
--- a/app/assets/javascripts/repository/pages/blob.vue
+++ b/app/assets/javascripts/repository/pages/blob.vue
@@ -3,11 +3,25 @@
// https://gitlab.com/gitlab-org/gitlab/-/issues/323200
import BlobContentViewer from '../components/blob_content_viewer.vue';
+import { LIMITED_CONTAINER_WIDTH_CLASS } from '../constants';
export default {
components: {
BlobContentViewer,
},
+ beforeRouteEnter(to, from, next) {
+ next(({ $options }) => {
+ $options.limitedContainerElements.forEach((el) =>
+ el.classList.remove(LIMITED_CONTAINER_WIDTH_CLASS),
+ );
+ });
+ },
+ beforeRouteLeave(to, from, next) {
+ this.$options.limitedContainerElements.forEach((el) =>
+ el.classList.add(LIMITED_CONTAINER_WIDTH_CLASS),
+ );
+ next();
+ },
props: {
path: {
type: String,
@@ -18,6 +32,7 @@ export default {
required: true,
},
},
+ limitedContainerElements: document.querySelectorAll(`.${LIMITED_CONTAINER_WIDTH_CLASS}`),
};
</script>
diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js
index 3e9e3e6f265..61fe89f4f7e 100644
--- a/app/assets/javascripts/rest_api.js
+++ b/app/assets/javascripts/rest_api.js
@@ -4,7 +4,7 @@ export * from './api/user_api';
export * from './api/markdown_api';
// Note: It's not possible to spy on methods imported from this file in
-// Jest tests. See https://stackoverflow.com/a/53307822/1063392.
+// Jest tests.
// As a workaround, in Jest tests, import the methods from the file
// in which they are defined:
//
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 36f5e6f4ce1..23254fcc2eb 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
-import { hide } from '~/tooltips';
+import { hide, fixTitle } from '~/tooltips';
import createFlash from './flash';
import axios from './lib/utils/axios_utils';
import { sprintf, s__, __ } from './locale';
@@ -75,6 +75,9 @@ Sidebar.prototype.sidebarToggleClicked = function (e, triggered) {
}
$this.attr('data-original-title', tooltipLabel);
+ $this.attr('title', tooltipLabel);
+ fixTitle($this);
+ hide($this);
if (!triggered) {
Cookies.set('collapsed_gutter', $('.right-sidebar').hasClass('right-sidebar-collapsed'));
@@ -99,7 +102,7 @@ Sidebar.prototype.toggleTodo = function (e) {
})
.catch(() =>
createFlash({
- message: sprintf(__('There was an error %{message} todo.'), {
+ message: sprintf(__('There was an error %{message} to-do item.'), {
message:
ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'),
}),
diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
index 23ecee449a4..fedd2519958 100644
--- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
+++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue
@@ -2,12 +2,16 @@
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
+import { formatNumber, sprintf, __ } from '~/locale';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
-import { INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
+import { statusTokenConfig } from '../components/search_tokens/status_token_config';
+import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
+import { typeTokenConfig } from '../components/search_tokens/type_token_config';
+import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import {
fromUrlQueryToSearch,
@@ -78,6 +82,21 @@ export default {
noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length;
},
+ activeRunnersMessage() {
+ return sprintf(__('Runners currently online: %{active_runners_count}'), {
+ active_runners_count: formatNumber(this.activeRunnersCount),
+ });
+ },
+ searchTokens() {
+ return [
+ statusTokenConfig,
+ typeTokenConfig,
+ {
+ ...tagTokenConfig,
+ recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
+ },
+ ];
+ },
},
watch: {
search: {
@@ -99,6 +118,7 @@ export default {
captureException({ error, component: this.$options.name });
},
},
+ filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
};
</script>
@@ -118,9 +138,13 @@ export default {
<runner-filtered-search-bar
v-model="search"
- namespace="admin_runners"
- :active-runners-count="activeRunnersCount"
- />
+ :tokens="searchTokens"
+ :namespace="$options.filteredSearchNamespace"
+ >
+ <template #runner-count>
+ {{ activeRunnersMessage }}
+ </template>
+ </runner-filtered-search-bar>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
index e14b3b17fa8..e04ca8ddca0 100644
--- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
+++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue
@@ -1,27 +1,8 @@
<script>
import { cloneDeep } from 'lodash';
-import { formatNumber, sprintf, __, s__ } from '~/locale';
-import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { __ } from '~/locale';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
-import {
- STATUS_ACTIVE,
- STATUS_PAUSED,
- STATUS_ONLINE,
- STATUS_OFFLINE,
- STATUS_NOT_CONNECTED,
- INSTANCE_TYPE,
- GROUP_TYPE,
- PROJECT_TYPE,
- CREATED_DESC,
- CREATED_ASC,
- CONTACTED_DESC,
- CONTACTED_ASC,
- PARAM_KEY_STATUS,
- PARAM_KEY_RUNNER_TYPE,
- PARAM_KEY_TAG,
-} from '../constants';
-import TagToken from './search_tokens/tag_token.vue';
+import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants';
const sortOptions = [
{
@@ -58,10 +39,6 @@ export default {
type: String,
required: true,
},
- activeRunnersCount: {
- type: Number,
- required: true,
- },
},
data() {
// filtered_search_bar_root.vue may mutate the inital
@@ -73,62 +50,6 @@ export default {
initialSortBy: sort,
};
},
- computed: {
- searchTokens() {
- return [
- {
- icon: 'status',
- title: __('Status'),
- type: PARAM_KEY_STATUS,
- token: BaseToken,
- unique: true,
- options: [
- { value: STATUS_ACTIVE, title: s__('Runners|Active') },
- { value: STATUS_PAUSED, title: s__('Runners|Paused') },
- { value: STATUS_ONLINE, title: s__('Runners|Online') },
- { value: STATUS_OFFLINE, title: s__('Runners|Offline') },
-
- // Added extra quotes in this title to avoid splitting this value:
- // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
- { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
- ],
- // TODO In principle we could support more complex search rules,
- // this can be added to a separate issue.
- operators: OPERATOR_IS_ONLY,
- },
-
- {
- icon: 'file-tree',
- title: __('Type'),
- type: PARAM_KEY_RUNNER_TYPE,
- token: BaseToken,
- unique: true,
- options: [
- { value: INSTANCE_TYPE, title: s__('Runners|instance') },
- { value: GROUP_TYPE, title: s__('Runners|group') },
- { value: PROJECT_TYPE, title: s__('Runners|project') },
- ],
- // TODO We should support more complex search rules,
- // search for multiple states (OR) or have NOT operators
- operators: OPERATOR_IS_ONLY,
- },
-
- {
- icon: 'tag',
- title: s__('Runners|Tags'),
- type: PARAM_KEY_TAG,
- token: TagToken,
- recentTokenValuesStorageKey: `${this.namespace}-recent-tags`,
- operators: OPERATOR_IS_ONLY,
- },
- ];
- },
- activeRunnersMessage() {
- return sprintf(__('Runners currently online: %{active_runners_count}'), {
- active_runners_count: formatNumber(this.activeRunnersCount),
- });
- },
- },
methods: {
onFilter(filters) {
const { sort } = this.value;
@@ -161,12 +82,13 @@ export default {
:sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
- :tokens="searchTokens"
:search-input-placeholder="__('Search or filter results...')"
data-testid="runners-filtered-search"
@onFilter="onFilter"
@onSort="onSort"
/>
- <div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div>
+ <div class="gl-text-right" data-testid="runner-count">
+ <slot name="runner-count"></slot>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/runner/components/runner_update_form.vue b/app/assets/javascripts/runner/components/runner_update_form.vue
index a5bc1680852..9a6fc07f6dd 100644
--- a/app/assets/javascripts/runner/components/runner_update_form.vue
+++ b/app/assets/javascripts/runner/components/runner_update_form.vue
@@ -135,9 +135,9 @@ export default {
</gl-form-checkbox>
<gl-form-checkbox
+ v-if="canBeLockedToProject"
v-model="model.locked"
data-testid="runner-field-locked"
- :disabled="!canBeLockedToProject"
>
{{ __('Lock to current projects') }}
<template #help>
diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
new file mode 100644
index 00000000000..03dff5e61a5
--- /dev/null
+++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js
@@ -0,0 +1,32 @@
+import { __, s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import {
+ STATUS_ACTIVE,
+ STATUS_PAUSED,
+ STATUS_ONLINE,
+ STATUS_OFFLINE,
+ STATUS_NOT_CONNECTED,
+ PARAM_KEY_STATUS,
+} from '../../constants';
+
+export const statusTokenConfig = {
+ icon: 'status',
+ title: __('Status'),
+ type: PARAM_KEY_STATUS,
+ token: BaseToken,
+ unique: true,
+ options: [
+ { value: STATUS_ACTIVE, title: s__('Runners|Active') },
+ { value: STATUS_PAUSED, title: s__('Runners|Paused') },
+ { value: STATUS_ONLINE, title: s__('Runners|Online') },
+ { value: STATUS_OFFLINE, title: s__('Runners|Offline') },
+
+ // Added extra quotes in this title to avoid splitting this value:
+ // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
+ { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
+ ],
+ // TODO In principle we could support more complex search rules,
+ // this can be added to a separate issue.
+ operators: OPERATOR_IS_ONLY,
+};
diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
index 51fae60b6b7..ab67ac608e2 100644
--- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
+++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue
@@ -33,6 +33,7 @@ export default {
// The API should
// 1) scope to the rights of the user
// 2) stay up to date to the removal of old tags
+ // 3) consider the scope of search, like searching within the tags of a group
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796
return axios
.get(TAG_SUGGESTIONS_PATH, {
diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/runner/components/search_tokens/tag_token_config.js
new file mode 100644
index 00000000000..fdeba714385
--- /dev/null
+++ b/app/assets/javascripts/runner/components/search_tokens/tag_token_config.js
@@ -0,0 +1,12 @@
+import { s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import { PARAM_KEY_TAG } from '../../constants';
+import TagToken from './tag_token.vue';
+
+export const tagTokenConfig = {
+ icon: 'tag',
+ title: s__('Runners|Tags'),
+ type: PARAM_KEY_TAG,
+ token: TagToken,
+ operators: OPERATOR_IS_ONLY,
+};
diff --git a/app/assets/javascripts/runner/components/search_tokens/type_token_config.js b/app/assets/javascripts/runner/components/search_tokens/type_token_config.js
new file mode 100644
index 00000000000..1da61c53386
--- /dev/null
+++ b/app/assets/javascripts/runner/components/search_tokens/type_token_config.js
@@ -0,0 +1,20 @@
+import { __, s__ } from '~/locale';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, PARAM_KEY_RUNNER_TYPE } from '../../constants';
+
+export const typeTokenConfig = {
+ icon: 'file-tree',
+ title: __('Type'),
+ type: PARAM_KEY_RUNNER_TYPE,
+ token: BaseToken,
+ unique: true,
+ options: [
+ { value: INSTANCE_TYPE, title: s__('Runners|instance') },
+ { value: GROUP_TYPE, title: s__('Runners|group') },
+ { value: PROJECT_TYPE, title: s__('Runners|project') },
+ ],
+ // TODO We should support more complex search rules,
+ // search for multiple states (OR) or have NOT operators
+ operators: OPERATOR_IS_ONLY,
+};
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 2822882e0cc..46e55b322c7 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -2,6 +2,7 @@ import { s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
+export const GROUP_RUNNER_COUNT_LIMIT = 1000;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
@@ -50,3 +51,8 @@ export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
export const CONTACTED_ASC = 'CONTACTED_ASC';
export const DEFAULT_SORT = CREATED_DESC;
+
+// Local storage namespaces
+
+export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners';
+export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners';
diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
new file mode 100644
index 00000000000..a601ee8d611
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql
@@ -0,0 +1,35 @@
+#import "~/runner/graphql/runner_node.fragment.graphql"
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getGroupRunners(
+ $groupFullPath: ID!
+ $before: String
+ $after: String
+ $first: Int
+ $last: Int
+ $status: CiRunnerStatus
+ $type: CiRunnerType
+ $search: String
+ $sort: CiRunnerSort
+) {
+ group(fullPath: $groupFullPath) {
+ runners(
+ membership: DESCENDANTS
+ before: $before
+ after: $after
+ first: $first
+ last: $last
+ status: $status
+ type: $type
+ search: $search
+ sort: $sort
+ ) {
+ nodes {
+ ...RunnerNode
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
index 07bbf60c453..42e1a9e1de9 100644
--- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue
+++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue
@@ -1,18 +1,135 @@
<script>
+import createFlash from '~/flash';
+import { fetchPolicies } from '~/lib/graphql';
+import { updateHistory } from '~/lib/utils/url_utility';
+import { formatNumber, sprintf, s__ } from '~/locale';
+import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
+import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
+import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
-import { GROUP_TYPE } from '../constants';
+import { statusTokenConfig } from '../components/search_tokens/status_token_config';
+import { typeTokenConfig } from '../components/search_tokens/type_token_config';
+import {
+ I18N_FETCH_ERROR,
+ GROUP_FILTERED_SEARCH_NAMESPACE,
+ GROUP_TYPE,
+ GROUP_RUNNER_COUNT_LIMIT,
+} from '../constants';
+import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql';
+import {
+ fromUrlQueryToSearch,
+ fromSearchToUrl,
+ fromSearchToVariables,
+} from '../runner_search_utils';
+import { captureException } from '../sentry_utils';
export default {
+ name: 'GroupRunnersApp',
components: {
+ RunnerFilteredSearchBar,
+ RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
+ RunnerPagination,
},
props: {
registrationToken: {
type: String,
required: true,
},
+ groupFullPath: {
+ type: String,
+ required: true,
+ },
+ groupRunnersLimitedCount: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ search: fromUrlQueryToSearch(),
+ runners: {
+ items: [],
+ pageInfo: {},
+ },
+ };
+ },
+ apollo: {
+ runners: {
+ query: getGroupRunnersQuery,
+ // Runners can be updated by users directly in this list.
+ // A "cache and network" policy prevents outdated filtered
+ // results.
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ variables() {
+ return this.variables;
+ },
+ update(data) {
+ const { runners } = data?.group || {};
+ return {
+ items: runners?.nodes || [],
+ pageInfo: runners?.pageInfo || {},
+ };
+ },
+ error(error) {
+ createFlash({ message: I18N_FETCH_ERROR });
+
+ this.reportToSentry(error);
+ },
+ },
+ },
+ computed: {
+ variables() {
+ return {
+ ...fromSearchToVariables(this.search),
+ groupFullPath: this.groupFullPath,
+ };
+ },
+ runnersLoading() {
+ return this.$apollo.queries.runners.loading;
+ },
+ noRunnersFound() {
+ return !this.runnersLoading && !this.runners.items.length;
+ },
+ groupRunnersCount() {
+ if (this.groupRunnersLimitedCount > GROUP_RUNNER_COUNT_LIMIT) {
+ return `${formatNumber(GROUP_RUNNER_COUNT_LIMIT)}+`;
+ }
+ return formatNumber(this.groupRunnersLimitedCount);
+ },
+ runnerCountMessage() {
+ return sprintf(s__('Runners|Runners in this group: %{groupRunnersCount}'), {
+ groupRunnersCount: this.groupRunnersCount,
+ });
+ },
+ searchTokens() {
+ return [statusTokenConfig, typeTokenConfig];
+ },
+ filteredSearchNamespace() {
+ return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
+ },
+ },
+ watch: {
+ search: {
+ deep: true,
+ handler() {
+ // TODO Implement back button reponse using onpopstate
+ updateHistory({
+ url: fromSearchToUrl(this.search),
+ title: document.title,
+ });
+ },
+ },
+ },
+ errorCaptured(error) {
+ this.reportToSentry(error);
+ },
+ methods: {
+ reportToSentry(error) {
+ captureException({ error, component: this.$options.name });
+ },
},
GROUP_TYPE,
};
@@ -31,5 +148,23 @@ export default {
/>
</div>
</div>
+
+ <runner-filtered-search-bar
+ v-model="search"
+ :tokens="searchTokens"
+ :namespace="filteredSearchNamespace"
+ >
+ <template #runner-count>
+ {{ runnerCountMessage }}
+ </template>
+ </runner-filtered-search-bar>
+
+ <div v-if="noRunnersFound" class="gl-text-center gl-p-5">
+ {{ __('No runners found') }}
+ </div>
+ <template v-else>
+ <runner-list :runners="runners.items" :loading="runnersLoading" />
+ <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js
index e14c583d73e..9545764c68d 100644
--- a/app/assets/javascripts/runner/group_runners/index.js
+++ b/app/assets/javascripts/runner/group_runners/index.js
@@ -12,7 +12,13 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
return null;
}
- const { registrationToken, groupId } = el.dataset;
+ const {
+ registrationToken,
+ runnerInstallHelpPage,
+ groupId,
+ groupFullPath,
+ groupRunnersLimitedCount,
+ } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
@@ -27,12 +33,15 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
el,
apolloProvider,
provide: {
+ runnerInstallHelpPage,
groupId,
},
render(h) {
return h(GroupRunnersApp, {
props: {
registrationToken,
+ groupFullPath,
+ groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10),
},
});
},
diff --git a/app/assets/javascripts/runner/runner_search_utils.js b/app/assets/javascripts/runner/runner_search_utils.js
index 65f75eb11ac..0a817ea0acf 100644
--- a/app/assets/javascripts/runner/runner_search_utils.js
+++ b/app/assets/javascripts/runner/runner_search_utils.js
@@ -43,7 +43,6 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
urlQueryToFilter(query, {
filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG],
filteredSearchTermKey: PARAM_KEY_SEARCH,
- legacySpacesDecode: false,
}),
),
sort: params[PARAM_KEY_SORT] || DEFAULT_SORT,
diff --git a/app/assets/javascripts/search/highlight_blob_search_result.js b/app/assets/javascripts/search/highlight_blob_search_result.js
index c553d5b14a0..07967434f37 100644
--- a/app/assets/javascripts/search/highlight_blob_search_result.js
+++ b/app/assets/javascripts/search/highlight_blob_search_result.js
@@ -2,7 +2,7 @@ export default (search = '') => {
const highlightLineClass = 'hll';
const contentBody = document.getElementById('content-body');
const searchTerm = search.toLowerCase();
- const blobs = contentBody.querySelectorAll('.blob-result');
+ const blobs = contentBody.querySelectorAll('.js-blob-result');
blobs.forEach((blob) => {
const lines = blob.querySelectorAll('.line');
diff --git a/app/assets/javascripts/search/store/actions.js b/app/assets/javascripts/search/store/actions.js
index ee5e778f63d..be64a9278e3 100644
--- a/app/assets/javascripts/search/store/actions.js
+++ b/app/assets/javascripts/search/store/actions.js
@@ -40,7 +40,7 @@ export const fetchProjects = ({ commit, state }, search) => {
);
} else {
// The .catch() is due to the API method not handling a rejection properly
- Api.projects(search, { order_by: 'id' }, callback).catch(() => {
+ Api.projects(search, { order_by: 'similarity' }, callback).catch(() => {
callback();
});
}
diff --git a/app/assets/javascripts/search/store/utils.js b/app/assets/javascripts/search/store/utils.js
index b7d97213594..b00b9bb0f2e 100644
--- a/app/assets/javascripts/search/store/utils.js
+++ b/app/assets/javascripts/search/store/utils.js
@@ -6,7 +6,7 @@ function extractKeys(object, keyList) {
}
export const loadDataFromLS = (key) => {
- if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (!AccessorUtilities.canUseLocalStorage()) {
return [];
}
@@ -20,7 +20,7 @@ export const loadDataFromLS = (key) => {
};
export const setFrequentItemToLS = (key, data, itemData) => {
- if (!AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (!AccessorUtilities.canUseLocalStorage()) {
return [];
}
diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js
index ebe0138f046..6a282df99bf 100644
--- a/app/assets/javascripts/security_configuration/components/constants.js
+++ b/app/assets/javascripts/security_configuration/components/constants.js
@@ -10,6 +10,7 @@ import {
REPORT_TYPE_CONTAINER_SCANNING,
REPORT_TYPE_CLUSTER_IMAGE_SCANNING,
REPORT_TYPE_COVERAGE_FUZZING,
+ REPORT_TYPE_CORPUS_MANAGEMENT,
REPORT_TYPE_API_FUZZING,
REPORT_TYPE_LICENSE_COMPLIANCE,
} from '~/vue_shared/security_reports/constants';
@@ -104,6 +105,12 @@ export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath(
{ anchor: 'configuration' },
);
+export const CORPUS_MANAGEMENT_NAME = __('Corpus Management');
+export const CORPUS_MANAGEMENT_DESCRIPTION = s__(
+ 'SecurityConfiguration|Manage corpus files used as mutation sources in coverage fuzzing.',
+);
+export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus');
+
export const API_FUZZING_NAME = __('API Fuzzing');
export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.');
export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index');
@@ -202,6 +209,14 @@ export const securityFeatures = [
helpPath: COVERAGE_FUZZING_HELP_PATH,
configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH,
type: REPORT_TYPE_COVERAGE_FUZZING,
+ secondary: gon?.features?.corpusManagement
+ ? {
+ type: REPORT_TYPE_CORPUS_MANAGEMENT,
+ name: CORPUS_MANAGEMENT_NAME,
+ description: CORPUS_MANAGEMENT_DESCRIPTION,
+ configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT,
+ }
+ : {},
},
];
diff --git a/app/assets/javascripts/sentry/sentry_config.js b/app/assets/javascripts/sentry/sentry_config.js
index a3a2c794a67..8f3c4c644bf 100644
--- a/app/assets/javascripts/sentry/sentry_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -19,7 +19,6 @@ const IGNORE_ERRORS = [
'fb_xd_fragment',
// ISP "optimizing" proxy - `Cache-Control: no-transform` seems to
// reduce this. (thanks @acdha)
- // See http://stackoverflow.com/questions/4113268
'bmi_SafeAddOnload',
'EBCallBackMessageReceived',
// See http://toolbar.conduit.com/Developer/HtmlAndGadget/Methods/JSInjection.aspx
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 e522e3ff408..b1c8f6ef22e 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,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import {
GlToast,
GlModal,
@@ -8,6 +7,7 @@ import {
GlFormCheckbox,
GlDropdown,
GlDropdownItem,
+ GlSafeHtmlDirective,
} from '@gitlab/ui';
import $ from 'jquery';
import Vue from 'vue';
@@ -49,6 +49,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -234,6 +235,7 @@ export default {
},
},
statusTimeRanges,
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -267,7 +269,7 @@ export default {
@click="setEmoji"
>
<template #button-content>
- <span v-html="emojiTag"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
<span
v-show="noEmoji"
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
@@ -289,7 +291,7 @@ export default {
class="js-toggle-emoji-menu emoji-menu-toggle-button btn"
@click="showEmojiMenu"
>
- <span v-html="emojiTag"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="emojiTag"></span>
<span
v-show="noEmoji"
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index d9c5edc91f1..f98aa0dc77d 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -53,7 +53,7 @@ export default {
class="js-sidebar-dropdown-toggle edit-link btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary float-right"
href="#"
data-test-id="edit-link"
- data-track-event="click_edit_button"
+ data-track-action="click_edit_button"
data-track-label="right_sidebar"
data-track-property="assignee"
>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
index 1dd05d3886e..1b28ba2afd1 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue
@@ -3,7 +3,6 @@ import { GlDropdownItem } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
-import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
import SidebarAssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
@@ -173,7 +172,7 @@ export default {
})
.then(({ data }) => {
this.$emit('assignees-updated', {
- id: getIdFromGraphQLId(data.issuableSetAssignees.issuable.id),
+ id: data.issuableSetAssignees.issuable.id,
assignees: data.issuableSetAssignees.issuable.assignees.nodes,
});
return data;
diff --git a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
index 55179947756..9fdf941579d 100644
--- a/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
+++ b/app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue
@@ -144,16 +144,11 @@ export default {
v-if="glFeatures.labelsWidget"
class="block labels js-labels-block"
:allow-label-remove="allowLabelEdit"
- :allow-label-create="allowLabelCreate"
- :allow-label-edit="allowLabelEdit"
:allow-multiselect="true"
- :allow-scoped-labels="allowScopedLabels"
:footer-create-label-title="__('Create project label')"
:footer-manage-label-title="__('Manage project labels')"
:labels-create-title="__('Create project label')"
- :labels-fetch-path="labelsFetchPath"
:labels-filter-base-path="projectIssuesPath"
- :labels-manage-path="labelsManagePath"
:labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.variant"
diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
index 19543d0927a..cb49f329f7e 100644
--- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
+++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue
@@ -93,7 +93,7 @@ export default {
class="float-right lock-edit btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary gl-mr-n2"
href="#"
data-testid="edit-link"
- data-track-event="click_edit_button"
+ data-track-action="click_edit_button"
data-track-label="right_sidebar"
data-track-property="lock_issue"
@click.prevent="toggleForm"
diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
index 39f72b251c7..a09138a708b 100644
--- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
+++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue
@@ -56,6 +56,11 @@ export default {
return this.$apollo.queries.participants.loading;
},
},
+ methods: {
+ toggleSidebar() {
+ this.$emit('toggleSidebar');
+ },
+ },
};
</script>
@@ -66,5 +71,6 @@ export default {
:number-of-less-participants="7"
:lazy="false"
class="block participants"
+ @toggleSidebar="toggleSidebar"
/>
</template>
diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
index 1243603805a..367dcdb961b 100644
--- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
+++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_title.vue
@@ -40,7 +40,7 @@ export default {
v-if="editable"
class="js-sidebar-dropdown-toggle edit-link btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary float-right"
href="#"
- data-track-event="click_edit_button"
+ data-track-action="click_edit_button"
data-track-label="right_sidebar"
data-track-property="reviewer"
>
diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
index 8ccc0102c3d..8f4d5406da8 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue
@@ -369,6 +369,7 @@ export default {
:text="dropdownText"
:loading="loading"
class="gl-w-full"
+ toggle-class="gl-max-w-100"
@shown="setFocus"
>
<gl-search-box-by-type ref="search" v-model="searchTerm" />
diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
index 89aa03fd954..22adbd79ef6 100644
--- a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
+++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue
@@ -136,7 +136,7 @@ export default {
size="small"
class="gl-text-gray-900! gl-ml-auto hide-collapsed gl-mr-n2"
data-testid="edit-button"
- :data-track-event="tracking.event"
+ :data-track-action="tracking.event"
:data-track-label="tracking.label"
:data-track-property="tracking.property"
data-qa-selector="edit_link"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
index 33c6ac6e2ba..db2197ec65e 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { sprintf, s__ } from '~/locale';
export default {
@@ -27,5 +26,5 @@ export default {
</script>
<template>
- <div data-testid="spentOnlyPane" v-html="timeSpent"></div>
+ <div data-testid="spentOnlyPane" v-html="timeSpent /* eslint-disable-line vue/no-v-html */"></div>
</template>
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 031472a7d20..10ab80f4ec2 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -1,7 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createFlash from '~/flash';
import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
@@ -13,7 +12,6 @@ import {
isInIncidentPage,
parseBoolean,
} from '~/lib/utils/common_utils';
-import { __ } from '~/locale';
import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
@@ -258,6 +256,8 @@ export function mountSidebarLabels() {
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
variant: DropdownVariant.Sidebar,
+ canUpdate: parseBoolean(el.dataset.canEdit),
+ isClassicSidebar: true,
},
render: (createElement) => createElement(SidebarLabels),
});
@@ -361,10 +361,10 @@ function mountReferenceComponent() {
});
}
-function mountLockComponent() {
+function mountLockComponent(store) {
const el = document.getElementById('js-lock-entry-point');
- if (!el) {
+ if (!el || !store) {
return;
}
@@ -373,37 +373,20 @@ function mountLockComponent() {
const dataNode = document.getElementById('js-lock-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
- let importStore;
- if (isInIssuePage() || isInIncidentPage()) {
- importStore = import(/* webpackChunkName: 'notesStore' */ '~/notes/stores').then(
- ({ store }) => store,
- );
- } else {
- importStore = import(/* webpackChunkName: 'mrNotesStore' */ '~/mr_notes/stores').then(
- (store) => store.default,
- );
- }
-
- importStore
- .then(
- (store) =>
- new Vue({
- el,
- store,
- provide: {
- fullPath,
- },
- render: (createElement) =>
- createElement(IssuableLockForm, {
- props: {
- isEditable: initialData.is_editable,
- },
- }),
- }),
- )
- .catch(() => {
- createFlash({ message: __('Failed to load sidebar lock status') });
- });
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ store,
+ provide: {
+ fullPath,
+ },
+ render: (createElement) =>
+ createElement(IssuableLockForm, {
+ props: {
+ isEditable: initialData.is_editable,
+ },
+ }),
+ });
}
function mountParticipantsComponent() {
@@ -535,7 +518,7 @@ function mountCopyEmailComponent() {
const isAssigneesWidgetShown =
(isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget;
-export function mountSidebar(mediator) {
+export function mountSidebar(mediator, store) {
initInviteMembersModal();
initInviteMembersTrigger();
@@ -546,11 +529,12 @@ export function mountSidebar(mediator) {
mountAssigneesComponentDeprecated(mediator);
}
mountReviewersComponent(mediator);
+ mountSidebarLabels();
mountMilestoneSelect();
mountConfidentialComponent(mediator);
mountDueDateComponent(mediator);
mountReferenceComponent(mediator);
- mountLockComponent();
+ mountLockComponent(store);
mountParticipantsComponent();
mountSubscriptionsComponent();
mountCopyEmailComponent();
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
index ace2a163adc..cea26acd101 100644
--- a/app/assets/javascripts/sidebar/services/sidebar_service.js
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -22,7 +22,6 @@ export default class SidebarService {
constructor(endpointMap) {
if (!SidebarService.singleton) {
this.endpoint = endpointMap.endpoint;
- this.toggleSubscriptionEndpoint = endpointMap.toggleSubscriptionEndpoint;
this.moveIssueEndpoint = endpointMap.moveIssueEndpoint;
this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint;
this.fullPath = endpointMap.fullPath;
@@ -75,10 +74,6 @@ export default class SidebarService {
});
}
- toggleSubscription() {
- return axios.post(this.toggleSubscriptionEndpoint);
- }
-
moveIssue(moveToProjectId) {
return axios.post(this.moveIssueEndpoint, {
move_to_project_id: moveToProjectId,
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
index 063e3313a3c..1be670f7590 100644
--- a/app/assets/javascripts/sidebar/sidebar_bundle.js
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -1,9 +1,9 @@
-import { mountSidebar, getSidebarOptions } from './mount_sidebar';
+import { mountSidebar, getSidebarOptions } from 'ee_else_ce/sidebar/mount_sidebar';
import Mediator from './sidebar_mediator';
-export default () => {
+export default (store) => {
const mediator = new Mediator(getSidebarOptions());
mediator.fetch();
- mountSidebar(mediator);
+ mountSidebar(mediator, store);
};
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 0a5e44a9b95..9144e3b08db 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -17,7 +17,6 @@ export default class SidebarMediator {
this.store = new Store(options);
this.service = new Service({
endpoint: options.endpoint,
- toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint,
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
fullPath: options.fullPath,
@@ -85,22 +84,6 @@ export default class SidebarMediator {
this.store.setAssigneeData(data);
this.store.setReviewerData(data);
this.store.setTimeTrackingData(data);
- this.store.setParticipantsData(data);
- this.store.setSubscriptionsData(data);
- }
-
- toggleSubscription() {
- this.store.setFetchingState('subscriptions', true);
- return this.service
- .toggleSubscription()
- .then(() => {
- this.store.setSubscribedState(!this.store.subscribed);
- this.store.setFetchingState('subscriptions', false);
- })
- .catch((err) => {
- this.store.setFetchingState('subscriptions', false);
- throw err;
- });
}
fetchAutocompleteProjects(searchTerm) {
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 3c108b06eab..94c54fc0980 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -22,8 +22,6 @@ export default class SidebarStore {
this.isFetching = {
assignees: true,
reviewers: true,
- participants: true,
- subscriptions: true,
};
this.isLoading = {};
this.autocompleteProjects = [];
@@ -63,18 +61,6 @@ export default class SidebarStore {
this.humanTotalTimeSpent = data.human_total_time_spent;
}
- setParticipantsData(data) {
- this.isFetching.participants = false;
- this.participants = data.participants || [];
- }
-
- setSubscriptionsData(data) {
- this.projectEmailsDisabled = data.project_emails_disabled || false;
- this.subscribeDisabledDescription = data.subscribe_disabled_description;
- this.isFetching.subscriptions = false;
- this.subscribed = data.subscribed || false;
- }
-
setFetchingState(key, value) {
this.isFetching[key] = value;
}
diff --git a/app/assets/javascripts/sidebar/track_invite_members.js b/app/assets/javascripts/sidebar/track_invite_members.js
index eab15578f0f..45a3366197b 100644
--- a/app/assets/javascripts/sidebar/track_invite_members.js
+++ b/app/assets/javascripts/sidebar/track_invite_members.js
@@ -2,10 +2,12 @@ import $ from 'jquery';
import Tracking from '~/tracking';
export default function initTrackInviteMembers(userDropdown) {
- const { trackEvent, trackLabel } = userDropdown.querySelector('.js-invite-members-track').dataset;
+ const { trackAction, trackLabel } = userDropdown.querySelector(
+ '.js-invite-members-track',
+ ).dataset;
$(userDropdown).on('shown.bs.dropdown', () => {
- Tracking.event(undefined, trackEvent, {
+ Tracking.event(undefined, trackAction, {
label: trackLabel,
});
});
diff --git a/app/assets/javascripts/snippet/snippet_show.js b/app/assets/javascripts/snippet/snippet_show.js
index 22dffa90cef..6d0e4770e1c 100644
--- a/app/assets/javascripts/snippet/snippet_show.js
+++ b/app/assets/javascripts/snippet/snippet_show.js
@@ -1,12 +1,12 @@
import loadAwardsHandler from '~/awards_handler';
-import initNotes from '~/init_notes';
+import initDeprecatedNotes from '~/init_deprecated_notes';
import SnippetsAppFactory from '~/snippets';
import SnippetsShow from '~/snippets/components/show.vue';
import ZenMode from '~/zen_mode';
SnippetsAppFactory(document.getElementById('js-snippet-view'), SnippetsShow);
-initNotes();
+initDeprecatedNotes();
loadAwardsHandler();
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue
index e462f20535b..62d95a650da 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
export default {
@@ -17,6 +16,9 @@ export default {
</script>
<template>
<markdown-field-view class="snippet-description" data-qa-selector="snippet_description_content">
- <div class="md js-snippet-description" v-html="description"></div>
+ <div
+ class="md js-snippet-description"
+ v-html="description /* eslint-disable-line vue/no-v-html */"
+ ></div>
</markdown-field-view>
</template>
diff --git a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js
index d770dd18d7f..e41dc51457a 100644
--- a/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js
+++ b/app/assets/javascripts/static_site_editor/rich_content_editor/services/renderers/render_identifier_instance_text.js
@@ -18,7 +18,7 @@ Regexp notes:
const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g;
const isIdentifierInstance = (literal) => {
- // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448)
+ // Reset lastIndex as global flag in regexp are stateful
identifierInstanceRegex.lastIndex = 0;
return identifierInstanceRegex.test(literal);
};
diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js
index 598111e4086..062a3404355 100644
--- a/app/assets/javascripts/tracking/constants.js
+++ b/app/assets/javascripts/tracking/constants.js
@@ -24,3 +24,7 @@ export const LOAD_ACTION_ATTR_SELECTOR = '[data-track-action="render"]';
export const DEPRECATED_EVENT_ATTR_SELECTOR = '[data-track-event]';
export const DEPRECATED_LOAD_EVENT_ATTR_SELECTOR = '[data-track-event="render"]';
+
+export const URLS_CACHE_STORAGE_KEY = 'gl-snowplow-pseudonymized-urls';
+
+export const REFERRER_TTL = 24 * 60 * 60 * 1000;
diff --git a/app/assets/javascripts/tracking/index.js b/app/assets/javascripts/tracking/index.js
index 5417e2d969b..7e99ecb4f4e 100644
--- a/app/assets/javascripts/tracking/index.js
+++ b/app/assets/javascripts/tracking/index.js
@@ -1,3 +1,4 @@
+import { getAllExperimentContexts } from '~/experimentation/utils';
import { DEFAULT_SNOWPLOW_OPTIONS } from './constants';
import getStandardContext from './get_standard_context';
import Tracking from './tracking';
@@ -38,10 +39,14 @@ export function initDefaultTrackers() {
const opts = { ...DEFAULT_SNOWPLOW_OPTIONS, ...window.snowplowOptions };
+ // must be before initializing the trackers
+ Tracking.setAnonymousUrls();
+
window.snowplow('enableActivityTracking', 30, 30);
// must be after enableActivityTracking
const standardContext = getStandardContext();
- window.snowplow('trackPageView', null, [standardContext]);
+ const experimentContexts = getAllExperimentContexts();
+ window.snowplow('trackPageView', null, [standardContext, ...experimentContexts]);
if (window.snowplowOptions.formTracking) {
Tracking.enableFormTracking(opts.formTrackingConfig);
diff --git a/app/assets/javascripts/tracking/tracking.js b/app/assets/javascripts/tracking/tracking.js
index a1f745bc172..657e0a79911 100644
--- a/app/assets/javascripts/tracking/tracking.js
+++ b/app/assets/javascripts/tracking/tracking.js
@@ -1,7 +1,14 @@
import { LOAD_ACTION_ATTR_SELECTOR, DEPRECATED_LOAD_EVENT_ATTR_SELECTOR } from './constants';
import { dispatchSnowplowEvent } from './dispatch_snowplow_event';
import getStandardContext from './get_standard_context';
-import { getEventHandlers, createEventPayload, renameKey, addExperimentContext } from './utils';
+import {
+ getEventHandlers,
+ createEventPayload,
+ renameKey,
+ addExperimentContext,
+ getReferrersCache,
+ addReferrersCacheEntry,
+} from './utils';
export default class Tracking {
static queuedEvents = [];
@@ -159,6 +166,37 @@ export default class Tracking {
}
/**
+ * Replaces the URL and referrer for the default web context
+ * if the replacements are available.
+ *
+ * @returns {undefined}
+ */
+ static setAnonymousUrls() {
+ const { snowplowPseudonymizedPageUrl: pageUrl } = window.gl;
+
+ if (!pageUrl) {
+ return;
+ }
+
+ const referrers = getReferrersCache();
+ const pageLinks = Object.seal({ url: '', referrer: '', originalUrl: window.location.href });
+
+ pageLinks.url = `${pageUrl}${window.location.hash}`;
+ window.snowplow('setCustomUrl', pageLinks.url);
+
+ if (document.referrer) {
+ const node = referrers.find((links) => links.originalUrl === document.referrer);
+
+ if (node) {
+ pageLinks.referrer = node.url;
+ window.snowplow('setReferrerUrl', pageLinks.referrer);
+ }
+ }
+
+ addReferrersCacheEntry(referrers, pageLinks);
+ }
+
+ /**
* Returns an implementation of this class in the form of
* a Vue mixin.
*
diff --git a/app/assets/javascripts/tracking/utils.js b/app/assets/javascripts/tracking/utils.js
index 1189b2168ad..3507872b511 100644
--- a/app/assets/javascripts/tracking/utils.js
+++ b/app/assets/javascripts/tracking/utils.js
@@ -6,6 +6,8 @@ import {
LOAD_ACTION_ATTR_SELECTOR,
DEPRECATED_EVENT_ATTR_SELECTOR,
DEPRECATED_LOAD_EVENT_ATTR_SELECTOR,
+ URLS_CACHE_STORAGE_KEY,
+ REFERRER_TTL,
} from './constants';
export const addExperimentContext = (opts) => {
@@ -100,3 +102,25 @@ export const renameKey = (o, oldKey, newKey) => {
return ret;
};
+
+export const filterOldReferrersCacheEntries = (cache) => {
+ const now = Date.now();
+
+ return cache.filter((entry) => entry.timestamp && entry.timestamp > now - REFERRER_TTL);
+};
+
+export const getReferrersCache = () => {
+ try {
+ const referrers = JSON.parse(window.localStorage.getItem(URLS_CACHE_STORAGE_KEY) || '[]');
+
+ return filterOldReferrersCacheEntries(referrers);
+ } catch {
+ return [];
+ }
+};
+
+export const addReferrersCacheEntry = (cache, entry) => {
+ const referrers = JSON.stringify([{ ...entry, timestamp: Date.now() }, ...cache]);
+
+ window.localStorage.setItem(URLS_CACHE_STORAGE_KEY, referrers);
+};
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index 0e25f71fe05..7a7518bcf83 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -1,7 +1,4 @@
import Vue from 'vue';
-
-import { sanitize } from '~/lib/dompurify';
-
import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
@@ -41,7 +38,6 @@ const populateUserInfo = (user) => {
name: userData.name,
location: userData.location,
bio: userData.bio,
- bioHtml: sanitize(userData.bio_html),
workInformation: userData.work_information,
websiteUrl: userData.website_url,
pronouns: userData.pronouns,
diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js
index 7c17ce85cc6..69b3c27173f 100644
--- a/app/assets/javascripts/users_select/index.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -536,9 +536,6 @@ function UsersSelect(currentUser, els, options = {}) {
opened(e) {
const $el = $(e.currentTarget);
const selected = getSelected();
- if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
- this.addInput($dropdown.data('fieldName'), 0, {});
- }
$el.find('.is-active').removeClass('is-active');
function highlightSelected(id) {
@@ -547,8 +544,6 @@ function UsersSelect(currentUser, els, options = {}) {
if (selected.length > 0) {
getSelected().forEach((selectedId) => highlightSelected(selectedId));
- } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- highlightSelected(0);
} else {
highlightSelected(selectedId);
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
index ac6368a3025..306026072a3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_related_links.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { s__, n__ } from '~/locale';
export default {
@@ -32,11 +31,16 @@ export default {
</script>
<template>
<section class="mr-info-list gl-ml-7 gl-pb-5">
- <p v-if="relatedLinks.closing">{{ closesText }} <span v-html="relatedLinks.closing"></span></p>
+ <p v-if="relatedLinks.closing">
+ {{ closesText }}
+ <span v-html="relatedLinks.closing /* eslint-disable-line vue/no-v-html */"></span>
+ </p>
<p v-if="relatedLinks.mentioned">
{{ n__('mrWidget|Mentions issue', 'mrWidget|Mentions issues', relatedLinks.mentionedCount) }}
- <span v-html="relatedLinks.mentioned"></span>
+ <span v-html="relatedLinks.mentioned /* eslint-disable-line vue/no-v-html */"></span>
+ </p>
+ <p v-if="relatedLinks.assignToMe">
+ <span v-html="relatedLinks.assignToMe /* eslint-disable-line vue/no-v-html */"></span>
</p>
- <p v-if="relatedLinks.assignToMe"><span v-html="relatedLinks.assignToMe"></span></p>
</section>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
index d2581f57837..f3673005c45 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue
@@ -98,7 +98,7 @@ export default {
data-testid="add-pipeline-link"
:data-track-property="humanAccess"
:data-track-value="$options.SP_LINK_TRACK_VALUE"
- :data-track-event="$options.SP_LINK_TRACK_EVENT"
+ :data-track-action="$options.SP_LINK_TRACK_EVENT"
:data-track-label="$options.SP_TRACK_LABEL"
>
{{ content }}
@@ -139,7 +139,7 @@ export default {
:href="pipelinePath"
:data-track-property="humanAccess"
:data-track-value="$options.SP_SHOW_TRACK_VALUE"
- :data-track-event="$options.SP_SHOW_TRACK_EVENT"
+ :data-track-action="$options.SP_SHOW_TRACK_EVENT"
:data-track-label="$options.SP_TRACK_LABEL"
>
{{ __('Show me how to add a pipeline') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
index ebd2b5cd22d..e31e69d0f3a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -39,7 +39,7 @@ export default {
target="_blank"
rel="noopener noreferrer nofollow"
:class="cssClass"
- data-track-event="open_review_app"
+ data-track-action="open_review_app"
data-track-label="review_app"
>
{{ display.text }} <gl-icon class="fgray" name="external-link" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
index d331f1690f5..a55dba92e16 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -1,5 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
+/* eslint-disable @gitlab/require-string-literal-i18n-helpers */
import { GlButton } from '@gitlab/ui';
import { escape } from 'lodash';
import { __, n__, sprintf, s__ } from '~/locale';
@@ -89,7 +89,10 @@ export default {
/>
<span v-if="expanded">{{ __('Collapse') }}</span>
<span v-else>
- <span class="vertical-align-middle" v-html="message"></span>
+ <span
+ class="vertical-align-middle"
+ v-html="message /* eslint-disable-line vue/no-v-html */"
+ ></span>
<gl-button variant="link" class="modify-message-button">
{{ modifyLinkMessage }}
</gl-button>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
new file mode 100644
index 00000000000..503ddf8a396
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/merge_checks_failed.vue
@@ -0,0 +1,75 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import notesEventHub from '~/notes/event_hub';
+import StatusIcon from '../mr_widget_status_icon.vue';
+
+export default {
+ i18n: {
+ pipelineFailed: s__(
+ 'mrWidget|The pipeline for this merge request did not complete. Push a new commit to fix the failure.',
+ ),
+ approvalNeeded: s__('mrWidget|You can only merge once this merge request is approved.'),
+ unresolvedDiscussions: s__('mrWidget|Merge blocked: all threads must be resolved.'),
+ },
+ components: {
+ StatusIcon,
+ GlButton,
+ },
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ failedText() {
+ if (this.mr.isPipelineFailed) {
+ return this.$options.i18n.pipelineFailed;
+ } else if (this.mr.approvals && !this.mr.isApproved) {
+ return this.$options.i18n.approvalNeeded;
+ } else if (this.mr.hasMergeableDiscussionsState) {
+ return this.$options.i18n.unresolvedDiscussions;
+ }
+
+ return null;
+ },
+ },
+ methods: {
+ jumpToFirstUnresolvedDiscussion() {
+ notesEventHub.$emit('jumpToFirstUnresolvedDiscussion');
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media gl-flex-wrap">
+ <status-icon status="warning" />
+ <p class="media-body gl-m-0! gl-font-weight-bold gl-text-black-normal!">
+ {{ failedText }}
+ <template v-if="failedText == $options.i18n.unresolvedDiscussions">
+ <gl-button
+ class="gl-ml-3"
+ size="small"
+ variant="confirm"
+ data-testid="jumpToUnresolved"
+ @click="jumpToFirstUnresolvedDiscussion"
+ >
+ {{ s__('mrWidget|Jump to first unresolved thread') }}
+ </gl-button>
+ <gl-button
+ v-if="mr.createIssueToResolveDiscussionsPath"
+ :href="mr.createIssueToResolveDiscussionsPath"
+ class="gl-ml-3"
+ size="small"
+ variant="confirm"
+ category="secondary"
+ data-testid="resolveIssue"
+ >
+ {{ s__('mrWidget|Create issue to resolve all threads') }}
+ </gl-button>
+ </template>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 22f41b43095..1976d3639a6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
import { escape } from 'lodash';
import createFlash from '~/flash';
@@ -171,7 +170,7 @@ export default {
v-if="!rebaseInProgress && !canPushToSourceBranch"
class="gl-font-weight-bold gl-ml-0!"
data-testid="rebase-message"
- v-html="fastForwardMergeText"
+ v-html="fastForwardMergeText /* eslint-disable-line vue/no-v-html */"
></span>
<div
v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue
new file mode 100644
index 00000000000..9a7743348ff
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/new_ready_to_merge.vue
@@ -0,0 +1,49 @@
+<script>
+import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
+import readyToMergeQuery from '../../queries/states/new_ready_to_merge.query.graphql';
+import StatusIcon from '../mr_widget_status_icon.vue';
+
+export default {
+ apollo: {
+ canMerge: {
+ query: readyToMergeQuery,
+ skip() {
+ return !this.mr || !window.gon?.features?.mergeRequestWidgetGraphql;
+ },
+ variables() {
+ return this.mergeRequestQueryVariables;
+ },
+ update: (data) => data?.project?.mergeRequest?.userPermissions?.canMerge,
+ },
+ },
+ components: {
+ StatusIcon,
+ },
+ mixins: [mergeRequestQueryVariablesMixin],
+ props: {
+ mr: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ canMerge: null,
+ };
+ },
+};
+</script>
+
+<template>
+ <div class="mr-widget-body media">
+ <status-icon status="success" />
+ <p class="media-body gl-m-0! gl-font-weight-bold">
+ <template v-if="canMerge">
+ {{ __('Ready to merge!') }}
+ </template>
+ <template v-else>
+ {{ __('Ready to merge by members who can write to the target branch.') }}
+ </template>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
index 01e0b91bd4a..7827c79cd31 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlButton, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlButton, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
import emptyStateSVG from 'icons/_mr_widget_empty_state.svg';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -11,6 +10,9 @@ export default {
GlSprintf,
GlLink,
},
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
props: {
mr: {
type: Object,
@@ -21,6 +23,7 @@ export default {
return { emptyStateSVG };
},
ciHelpPage: helpPagePath('/ci/quick_start/index.html'),
+ safeHtmlConfig: { ADD_TAGS: ['use'] },
};
</script>
@@ -30,7 +33,7 @@ export default {
<div
class="artwork col-md-5 order-md-last col-12 text-center d-flex justify-content-center align-items-center"
>
- <span v-html="emptyStateSVG"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="emptyStateSVG"></span>
</div>
<div class="text col-md-7 order-md-first col-12">
<p class="highlight">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index f33f4d3fda0..7df65e995a5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -28,6 +28,7 @@ import {
CONFIRM,
WARNING,
MT_MERGE_STRATEGY,
+ PIPELINE_FAILED_STATE,
} from '../../constants';
import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
@@ -39,7 +40,6 @@ import CommitsHeader from './commits_header.vue';
import SquashBeforeMerge from './squash_before_merge.vue';
const PIPELINE_RUNNING_STATE = 'running';
-const PIPELINE_FAILED_STATE = 'failed';
const PIPELINE_PENDING_STATE = 'pending';
const PIPELINE_SUCCESS_STATE = 'success';
@@ -105,6 +105,10 @@ export default {
import(
'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
),
+ MergeTrainFailedPipelineConfirmationDialog: () =>
+ import(
+ 'ee_component/vue_merge_request_widget/components/merge_train_failed_pipeline_confirmation_dialog.vue'
+ ),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -125,6 +129,7 @@ export default {
squashBeforeMerge: this.mr.squashIsSelected,
isSquashReadOnly: this.mr.squashIsReadonly,
squashCommitMessage: this.mr.squashCommitMessage,
+ isPipelineFailedModalVisible: false,
};
},
computed: {
@@ -327,7 +332,12 @@ export default {
: this.mr.commitMessageWithDescription;
this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage;
},
- handleMergeButtonClick(useAutoMerge, mergeImmediately = false) {
+ handleMergeButtonClick(useAutoMerge, mergeImmediately = false, confirmationClicked = false) {
+ if (this.showFailedPipelineModal && !confirmationClicked) {
+ this.isPipelineFailedModalVisible = true;
+ return;
+ }
+
if (mergeImmediately) {
this.isMergingImmediately = true;
}
@@ -386,7 +396,7 @@ export default {
}
},
onMergeImmediatelyConfirmation() {
- this.handleMergeButtonClick(false, true);
+ this.handleMergeButtonClick(false, true, true);
},
initiateMergePolling() {
simplePoll(
@@ -522,6 +532,11 @@ export default {
@mergeImmediately="onMergeImmediatelyConfirmation"
/>
</gl-dropdown>
+ <merge-train-failed-pipeline-confirmation-dialog
+ :visible="isPipelineFailedModalVisible"
+ @startMergeTrain="onStartMergeTrainConfirmation"
+ @cancel="isPipelineFailedModalVisible = false"
+ />
</gl-button-group>
<div
v-if="shouldShowMergeControls"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index c6ce29acb09..69e4df0ca11 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -46,7 +46,7 @@ export default {
size="small"
icon="issue-new"
>
- {{ s__('mrWidget|Resolve all threads in new issue') }}
+ {{ s__('mrWidget|Create issue to resolve all threads') }}
</gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index a1eb77479bd..393c599c7e8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -124,7 +124,7 @@ export default {
},
}) => {
createFlash({
- message: __('The merge request can now be merged.'),
+ message: __('Marked as ready. Merging is now allowed.'),
type: 'notice',
});
$('.merge-request .detail-page-description .title').text(title);
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue
index 427ab0842ea..87a310efe78 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue
@@ -104,7 +104,7 @@ export default {
:href="plan.job_path"
target="_blank"
data-testid="terraform-report-link"
- data-track-event="click_terraform_mr_plan_button"
+ data-track-action="click_terraform_mr_plan_button"
data-track-label="mr_widget_terraform_mr_plan_button"
data-track-property="terraform_mr_plan_button"
class="btn btn-sm"
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
index d067e531fad..f5710f46b7e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/constants.js
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -10,6 +10,8 @@ export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds';
export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds';
export const MT_MERGE_STRATEGY = 'merge_train';
+export const PIPELINE_FAILED_STATE = 'failed';
+
export const AUTO_MERGE_STRATEGIES = [MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY];
// SP - "Suggest Pipelines"
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 3a3a1329483..f5dbcec7dbe 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -6,9 +6,8 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '../vue_shared/translate';
-import { registerExtension } from './components/extensions';
-import issueExtension from './extensions/issues';
Vue.use(Translate);
Vue.use(VueApollo);
@@ -28,13 +27,13 @@ export default () => {
gl.mrWidgetData.gitlabLogo = gon.gitlab_logo;
gl.mrWidgetData.defaultAvatarUrl = gon.default_avatar_url;
- registerExtension(issueExtension);
-
const vm = new Vue({
el: '#js-vue-mr-widget',
provide: {
artifactsEndpoint: gl.mrWidgetData.artifacts_endpoint,
artifactsEndpointPlaceholder: gl.mrWidgetData.artifacts_endpoint_placeholder,
+ falsePositiveDocUrl: gl.mrWidgetData.false_positive_doc_url,
+ canViewFalsePositive: parseBoolean(gl.mrWidgetData.can_view_false_positive),
},
...MrWidgetOptions,
apolloProvider,
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 23215982e6e..9d8e5d12d58 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -38,5 +38,13 @@ export default {
pipelineId() {
return this.pipeline.id;
},
+ showFailedPipelineModal() {
+ return false;
+ },
+ },
+ methods: {
+ onStartMergeTrainConfirmation() {
+ return false;
+ },
},
};
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 a8a9df598f5..78aa3941bfe 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
@@ -12,9 +12,6 @@ import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
import { setFaviconOverlay } from '../lib/utils/favicon';
-import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
-import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_codequality_reports_app.vue';
-import GroupedTestReportsApp from '../reports/grouped_test_report/grouped_test_reports_app.vue';
import Loading from './components/loading.vue';
import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import WidgetHeader from './components/mr_widget_header.vue';
@@ -42,7 +39,6 @@ import ShaMismatch from './components/states/sha_mismatch.vue';
import UnresolvedDiscussionsState from './components/states/unresolved_discussions.vue';
import WorkInProgressState from './components/states/work_in_progress.vue';
// import ExtensionsContainer from './components/extensions/container';
-import TerraformPlan from './components/terraform/mr_widget_terraform_container.vue';
import eventHub from './event_hub';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import getStateQuery from './queries/get_state.query.graphql';
@@ -72,7 +68,9 @@ export default {
'mr-widget-nothing-to-merge': NothingToMergeState,
'mr-widget-not-allowed': NotAllowedState,
'mr-widget-missing-branch': MissingBranchState,
- 'mr-widget-ready-to-merge': ReadyToMergeState,
+ 'mr-widget-ready-to-merge': window.gon?.features?.restructuredMrWidget
+ ? () => import('./components/states/new_ready_to_merge.vue')
+ : ReadyToMergeState,
'sha-mismatch': ShaMismatch,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
@@ -82,12 +80,16 @@ export default {
'mr-widget-auto-merge-failed': AutoMergeFailed,
'mr-widget-rebase': RebaseState,
SourceBranchRemovalStatus,
- GroupedCodequalityReportsApp,
- GroupedTestReportsApp,
- TerraformPlan,
- GroupedAccessibilityReportsApp,
+ GroupedCodequalityReportsApp: () =>
+ import('../reports/codequality_report/grouped_codequality_reports_app.vue'),
+ GroupedTestReportsApp: () =>
+ import('../reports/grouped_test_report/grouped_test_reports_app.vue'),
+ TerraformPlan: () => import('./components/terraform/mr_widget_terraform_container.vue'),
+ GroupedAccessibilityReportsApp: () =>
+ import('../reports/accessibility_report/grouped_accessibility_reports_app.vue'),
MrWidgetApprovals,
SecurityReportsApp: () => import('~/vue_shared/security_reports/security_reports_app.vue'),
+ MergeChecksFailed: () => import('./components/states/merge_checks_failed.vue'),
},
apollo: {
state: {
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql
new file mode 100644
index 00000000000..3b34be73c15
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/new_ready_to_merge.query.graphql
@@ -0,0 +1,9 @@
+query readyToMergeQuery($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $iid) {
+ userPermissions {
+ canMerge
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 04800cf43f0..65d78fc283c 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -1,7 +1,9 @@
import { stateKey } from './state_maps';
export default function deviseState() {
- if (this.projectArchived) {
+ if (this.hasMergeChecksFailed) {
+ return stateKey.mergeChecksFailed;
+ } else if (this.projectArchived) {
return stateKey.archived;
} else if (this.branchMissing) {
return stateKey.missingBranch;
@@ -25,7 +27,7 @@ export default function deviseState() {
return stateKey.shaMismatch;
} else if (this.autoMergeEnabled && !this.mergeError) {
return stateKey.autoMergeEnabled;
- } else if (!this.canMerge) {
+ } else if (!this.canMerge && !window.gon?.features?.restructuredMrWidget) {
return stateKey.notAllowedToMerge;
} else if (this.canBeMerged) {
return stateKey.readyToMerge;
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 8979fe621ac..29e0c867f6b 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
@@ -347,4 +347,13 @@ export default class MergeRequestStore {
this.approvals = data;
this.isApproved = data.approved || false;
}
+
+ get hasMergeChecksFailed() {
+ if (!window.gon?.features?.restructuredMrWidget) return false;
+
+ return (
+ this.hasMergeableDiscussionsState ||
+ (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed)
+ );
+ }
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
index 28507bba3e5..04454882666 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js
@@ -18,6 +18,7 @@ const stateToComponentMap = {
autoMergeFailed: 'mr-widget-auto-merge-failed',
shaMismatch: 'sha-mismatch',
rebase: 'mr-widget-rebase',
+ mergeChecksFailed: 'mergeChecksFailed',
};
const statesToShowHelpWidget = [
@@ -50,6 +51,7 @@ export const stateKey = {
readyToMerge: 'readyToMerge',
rebase: 'rebase',
merged: 'merged',
+ mergeChecksFailed: 'mergeChecksFailed',
};
export default {
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
index 3705e36a579..f8f1613879f 100644
--- a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui';
import NoteHeader from '~/notes/components/note_header.vue';
@@ -40,7 +39,7 @@ export default {
<div class="note-header">
<note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
- <span v-html="note.bodyHtml"></span>
+ <span v-html="note.bodyHtml /* eslint-disable-line vue/no-v-html */"></span>
</note-header>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index f4c73d12923..82a28d4cb5f 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlButton, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui';
import { groupBy } from 'lodash';
import EmojiPicker from '~/emoji/components/picker.vue';
import { __, sprintf } from '~/locale';
@@ -18,6 +17,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
@@ -164,6 +164,7 @@ export default {
this.isMenuOpen = menuOpen;
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -180,7 +181,11 @@ export default {
@click="handleAward(awardList.name)"
>
<template #emoji>
- <span class="award-emoji-block" data-testid="award-html" v-html="awardList.html"></span>
+ <span
+ v-safe-html:[$options.safeHtmlConfig]="awardList.html"
+ class="award-emoji-block"
+ data-testid="award-html"
+ ></span>
</template>
<span class="js-counter">{{ awardList.list.length }}</span>
</gl-button>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index 0589b47edbd..84770dbac6f 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { HIGHLIGHT_CLASS_NAME } from './constants';
@@ -75,7 +74,9 @@ export default {
</a>
</div>
<div class="blob-content">
- <pre class="code highlight"><code :data-blob-hash="blobHash" v-html="content"></code></pre>
+ <pre
+ class="code highlight"
+ ><code :data-blob-hash="blobHash" v-html="content /* eslint-disable-line vue/no-v-html */"></code></pre>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue
index 1928bf6dac5..9856f35c7f6 100644
--- a/app/assets/javascripts/vue_shared/components/code_block.vue
+++ b/app/assets/javascripts/vue_shared/components/code_block.vue
@@ -24,8 +24,13 @@ export default {
return isScrollable ? scrollableStyles : null;
},
},
+ userColorScheme: window.gon.user_color_scheme,
};
</script>
<template>
- <pre class="code-block rounded" :style="styleObject"><code class="d-block">{{ code }}</code></pre>
+ <pre
+ class="code-block rounded code"
+ :class="$options.userColorScheme"
+ :style="styleObject"
+ ><code class="d-block">{{ code }}</code></pre>
</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
index 0ff33e462b4..3c21b14894b 100644
--- a/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/color_picker/color_picker.vue
@@ -110,7 +110,7 @@ export default {
<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"
+ class="gl-absolute gl-top-0 gl-left-0 gl-h-full! gl-p-0! gl-m-0! gl-opacity-0"
tabindex="-1"
:value="value"
@input="handleColorChange"
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index d1eee62683b..5f50a699034 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -136,6 +136,9 @@ export default {
refUrl() {
return this.commitRef.ref_url || this.commitRef.path;
},
+ tooltipTitle() {
+ return this.mergeRequestRef ? this.mergeRequestRef.title : this.commitRef.name;
+ },
},
};
</script>
@@ -148,23 +151,14 @@ export default {
<gl-icon v-else name="branch" />
</div>
- <gl-link
- v-if="mergeRequestRef"
- v-gl-tooltip
- :href="mergeRequestRef.path"
- :title="mergeRequestRef.title"
- class="ref-name"
- >{{ mergeRequestRef.iid }}</gl-link
- >
- <gl-link
- v-else
- v-gl-tooltip
- :href="refUrl"
- :title="commitRef.name"
- class="ref-name"
- data-testid="ref-name"
- >{{ commitRef.name }}</gl-link
- >
+ <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top">
+ <gl-link v-if="mergeRequestRef" :href="mergeRequestRef.path" class="ref-name">
+ {{ mergeRequestRef.iid }}
+ </gl-link>
+ <gl-link v-else :href="refUrl" class="ref-name" data-testid="ref-name">
+ {{ commitRef.name }}
+ </gl-link>
+ </tooltip-on-truncate>
</template>
<gl-icon name="commit" class="commit-icon js-commit-icon" />
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 3790a509f26..7b88b36aa0f 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
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
@@ -110,6 +109,10 @@ export default {
<template>
<div ref="markdownPreview" class="md-previewer" data-testid="md-previewer">
<gl-skeleton-loading v-if="isLoading" />
- <div v-else class="md gl-ml-auto gl-mr-auto" v-html="previewContent"></div>
+ <div
+ v-else
+ class="md gl-ml-auto gl-mr-auto"
+ v-html="previewContent /* eslint-disable-line vue/no-v-html */"
+ ></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue
new file mode 100644
index 00000000000..56e6399a1b7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue
@@ -0,0 +1,159 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ GlSprintf,
+} from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { __, n__, s__, sprintf } from '~/locale';
+
+export const i18n = {
+ messageAdditionsDeletions: s__('Diffs|with %{additions} and %{deletions}'),
+ noFilesFound: __('No files found.'),
+ noFileNameAvailable: s__('Diffs|No file name available'),
+ searchFiles: __('Search files'),
+};
+
+export default {
+ i18n,
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownText,
+ GlSearchBoxByType,
+ GlSprintf,
+ },
+ props: {
+ changed: {
+ type: Number,
+ required: true,
+ },
+ added: {
+ type: Number,
+ required: true,
+ },
+ deleted: {
+ type: Number,
+ required: true,
+ },
+ files: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ search: '',
+ };
+ },
+ computed: {
+ filteredFiles() {
+ return this.search.length > 0
+ ? fuzzaldrinPlus.filter(this.files, this.search, { key: 'name' })
+ : this.files;
+ },
+ messageChanged() {
+ return sprintf(
+ n__(
+ 'Diffs|Showing %{dropdownStart}%{count} changed file%{dropdownEnd}',
+ 'Diffs|Showing %{dropdownStart}%{count} changed files%{dropdownEnd}',
+ this.changed,
+ ),
+ { count: this.changed },
+ );
+ },
+
+ additionsText() {
+ return n__('Diffs|%d addition', 'Diffs|%d additions', this.added);
+ },
+ deletionsText() {
+ return n__('Diffs|%d deletion', 'Diffs|%d deletions', this.deleted);
+ },
+ },
+ methods: {
+ jumpToFile(fileHash) {
+ window.location.hash = fileHash;
+ },
+ focusInput() {
+ this.$refs.search.focusInput();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-sprintf :message="messageChanged">
+ <template #dropdown="{ content: dropdownText }">
+ <gl-dropdown
+ category="tertiary"
+ variant="confirm"
+ :text="dropdownText"
+ data-testid="diff-stats-dropdown"
+ class="gl-vertical-align-baseline"
+ toggle-class="gl-px-0! gl-font-weight-bold!"
+ menu-class="gl-w-auto!"
+ no-flip
+ @shown="focusInput"
+ >
+ <template #header>
+ <gl-search-box-by-type
+ ref="search"
+ v-model.trim="search"
+ :placeholder="$options.i18n.searchFiles"
+ />
+ </template>
+ <gl-dropdown-item
+ v-for="file in filteredFiles"
+ :key="file.href"
+ :icon-name="file.icon"
+ :icon-color="file.iconColor"
+ @click="jumpToFile(file.href)"
+ >
+ <div class="gl-display-flex">
+ <span v-if="file.name" class="gl-font-weight-bold gl-mr-3 gl-text-truncate">{{
+ file.name
+ }}</span>
+ <span v-else class="gl-mr-3 gl-font-weight-bold gl-font-style-italic gl-gray-400">{{
+ $options.i18n.noFileNameAvailable
+ }}</span>
+ <span class="gl-ml-auto gl-white-space-nowrap">
+ <span class="gl-text-green-600">+{{ file.added }}</span>
+ <span class="gl-text-red-500">-{{ file.removed }}</span>
+ </span>
+ </div>
+ <div class="gl-text-gray-700 gl-overflow-hidden gl-text-overflow-ellipsis">
+ {{ file.path }}
+ </div>
+ </gl-dropdown-item>
+ <gl-dropdown-text v-if="!filteredFiles.length">
+ {{ $options.i18n.noFilesFound }}
+ </gl-dropdown-text>
+ </gl-dropdown>
+ </template>
+ </gl-sprintf>
+ <span
+ class="diff-stats-additions-deletions-expanded"
+ data-testid="diff-stats-additions-deletions-expanded"
+ >
+ <gl-sprintf :message="$options.i18n.messageAdditionsDeletions">
+ <template #additions>
+ <span class="gl-text-green-600 gl-font-weight-bold">{{ additionsText }}</span>
+ </template>
+ <template #deletions>
+ <span class="gl-text-red-500 gl-font-weight-bold">{{ deletionsText }}</span>
+ </template>
+ </gl-sprintf>
+ </span>
+
+ <div
+ class="diff-stats-additions-deletions-collapsed gl-float-right gl-display-none"
+ data-testid="diff-stats-additions-deletions-collapsed"
+ >
+ <span class="gl-text-green-600 gl-font-weight-bold">+{{ added }}</span>
+ <span class="gl-text-red-500 gl-font-weight-bold">-{{ deleted }}</span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
index 2e9634819a0..1df65d0a666 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js
@@ -20,19 +20,26 @@ export const OPERATOR_IS_ONLY = [{ value: OPERATOR_IS, description: OPERATOR_IS_
export const OPERATOR_IS_NOT_ONLY = [{ value: OPERATOR_IS_NOT, description: OPERATOR_IS_NOT_TEXT }];
export const OPERATOR_IS_AND_IS_NOT = [...OPERATOR_IS_ONLY, ...OPERATOR_IS_NOT_ONLY];
-export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __(FILTER_NONE) };
-export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __(FILTER_ANY) };
+export const DEFAULT_LABEL_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') };
+export const DEFAULT_LABEL_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') };
export const DEFAULT_NONE_ANY = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
export const DEFAULT_ITERATIONS = DEFAULT_NONE_ANY.concat([
- { value: FILTER_CURRENT, text: __(FILTER_CURRENT) },
+ { value: FILTER_CURRENT, text: __('Current') },
]);
export const DEFAULT_MILESTONES = DEFAULT_NONE_ANY.concat([
- { value: FILTER_UPCOMING, text: __(FILTER_UPCOMING) },
- { value: FILTER_STARTED, text: __(FILTER_STARTED) },
+ { value: FILTER_UPCOMING, text: __('Upcoming'), title: __('Upcoming') },
+ { value: FILTER_STARTED, text: __('Started'), title: __('Started') },
]);
+export const DEFAULT_MILESTONES_GRAPHQL = [
+ { value: 'any', text: __('Any'), title: __('Any') },
+ { value: 'none', text: __('None'), title: __('None') },
+ { value: '#upcoming', text: __('Upcoming'), title: __('Upcoming') },
+ { value: '#started', text: __('Started'), title: __('Started') },
+];
+
export const SortDirection = {
descending: 'descending',
ascending: 'ascending',
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
index 6573f366b52..5cc96471aef 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js
@@ -177,13 +177,10 @@ function filteredSearchTermValue(value) {
* @param {Object} options
* @param {String} [options.filteredSearchTermKey] if set, a FILTERED_SEARCH_TERM filter is created to this parameter. `'search'` is suggested
* @param {String[]} [options.filterNamesAllowList] if set, only this list of filters names is mapped
- * @param {Boolean} [options.legacySpacesDecode] if set, plus symbols (+) are not encoded as spaces. `false` is suggested
* @return {Object} filter object with filter names and their values
*/
-export function urlQueryToFilter(query = '', options = {}) {
- const { filteredSearchTermKey, filterNamesAllowList, legacySpacesDecode = true } = options;
-
- const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode });
+export function urlQueryToFilter(query = '', { filteredSearchTermKey, filterNamesAllowList } = {}) {
+ const filters = queryToObject(query, { gatherArrays: true });
return Object.keys(filters).reduce((memo, key) => {
const value = filters[key];
if (!value) {
@@ -222,7 +219,7 @@ export function urlQueryToFilter(query = '', options = {}) {
*/
export function getRecentlyUsedSuggestions(recentSuggestionsStorageKey) {
let recentlyUsedSuggestions = [];
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (AccessorUtilities.canUseLocalStorage()) {
recentlyUsedSuggestions = JSON.parse(localStorage.getItem(recentSuggestionsStorageKey)) || [];
}
return recentlyUsedSuggestions;
@@ -240,7 +237,7 @@ export function setTokenValueToRecentlyUsed(recentSuggestionsStorageKey, tokenVa
recentlyUsedSuggestions.splice(0, 0, { ...tokenValue });
- if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ if (AccessorUtilities.canUseLocalStorage()) {
localStorage.setItem(
recentSuggestionsStorageKey,
JSON.stringify(uniqWith(recentlyUsedSuggestions, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)),
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
index 4b9ad6d8f91..523438f459c 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue
@@ -39,8 +39,16 @@ export default {
},
methods: {
getActiveMilestone(milestones, data) {
- return milestones.find(
- (milestone) => milestone.title.toLowerCase() === stripQuotes(data).toLowerCase(),
+ /* We need to check default milestones against the value not the
+ * title because there is a discrepancy between the value graphql
+ * accepts and the title.
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/337687#note_648058797
+ */
+
+ return (
+ milestones.find(
+ (milestone) => milestone.title.toLowerCase() === stripQuotes(data).toLowerCase(),
+ ) || this.defaultMilestones.find(({ value }) => value === data)
);
},
fetchMilestones(searchTerm) {
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index f169921d8a6..41613bb3307 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,6 +1,5 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlTooltipDirective, GlLink, GlButton, GlTooltip } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlTooltip, GlSafeHtmlDirective } from '@gitlab/ui';
import { glEmojiTag } from '../../emoji';
import { __, sprintf } from '../../locale';
import CiIconBadge from './ci_badge_link.vue';
@@ -25,6 +24,7 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
+ SafeHtml: GlSafeHtmlDirective,
},
EMOJI_REF: 'EMOJI_REF',
props: {
@@ -37,8 +37,9 @@ export default {
required: true,
},
itemId: {
- type: Number,
- required: true,
+ type: String,
+ required: false,
+ default: '',
},
time: {
type: String,
@@ -86,6 +87,13 @@ export default {
message() {
return this.user?.status?.message;
},
+ item() {
+ if (this.itemId) {
+ return `${this.itemName} #${this.itemId}`;
+ }
+
+ return this.itemName;
+ },
},
methods: {
@@ -93,6 +101,7 @@ export default {
this.$emit('clickedSidebarButton');
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -105,7 +114,7 @@ export default {
<section class="header-main-content gl-mr-3">
<ci-icon-badge :status="status" />
- <strong data-testid="ci-header-item-text"> {{ itemName }} #{{ itemId }} </strong>
+ <strong data-testid="ci-header-item-text">{{ item }}</strong>
<template v-if="shouldRenderTriggeredLabel">{{ __('triggered') }}</template>
<template v-else>{{ __('created') }}</template>
@@ -130,8 +139,8 @@ export default {
<span
v-if="statusTooltipHTML"
:ref="$options.EMOJI_REF"
+ v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML"
:data-testid="message"
- v-html="statusTooltipHTML"
></span>
</template>
</section>
diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
index 18bfcc268dc..28aa93d6680 100644
--- a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
+++ b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js
@@ -1,10 +1,20 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableHeaderWarnings from './issuable_header_warnings.vue';
export default function issuableHeaderWarnings(store) {
+ const el = document.getElementById('js-issuable-header-warnings');
+
+ if (!el) {
+ return false;
+ }
+
+ const { hidden } = el.dataset;
+
return new Vue({
- el: document.getElementById('js-issuable-header-warnings'),
+ el,
store,
+ provide: { hidden: parseBoolean(hidden) },
render(createElement) {
return createElement(IssuableHeaderWarnings);
},
diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue
index 56adbe8c606..82223ab9ef4 100644
--- a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue
+++ b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue
@@ -1,11 +1,16 @@
<script>
-import { GlIcon } from '@gitlab/ui';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
+import { __ } from '~/locale';
export default {
components: {
GlIcon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ inject: ['hidden'],
computed: {
...mapGetters(['getNoteableData']),
isLocked() {
@@ -26,6 +31,12 @@ export default {
visible: this.isConfidential,
dataTestId: 'confidential',
},
+ {
+ iconName: 'spam',
+ visible: this.hidden,
+ dataTestId: 'hidden',
+ tooltip: __('This issue is hidden because its author has been banned'),
+ },
];
},
},
@@ -35,8 +46,15 @@ export default {
<template>
<div class="gl-display-inline-block">
<template v-for="meta in warningIconsMeta">
- <div v-if="meta.visible" :key="meta.iconName" class="issuable-warning-icon inline">
- <gl-icon :name="meta.iconName" :data-testid="meta.dataTestId" class="icon" />
+ <div
+ v-if="meta.visible"
+ :key="meta.iconName"
+ v-gl-tooltip
+ :data-testid="meta.dataTestId"
+ :title="meta.tooltip || null"
+ class="issuable-warning-icon inline"
+ >
+ <gl-icon :name="meta.iconName" class="icon" />
</div>
</template>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index ccdb47e3144..095d1854c8b 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import '~/commons/bootstrap';
import { GlIcon, GlTooltip, GlTooltipDirective, GlButton } from '@gitlab/ui';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
@@ -72,7 +71,7 @@ export default {
class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7"
>
<!-- Title area: Status icon (XL) and title -->
- <div class="item-title d-flex align-items-xl-center mb-xl-0">
+ <div class="item-title d-flex align-items-xl-center mb-xl-0 gl-min-w-0">
<div ref="iconElementXL">
<gl-icon
v-if="hasState"
@@ -85,7 +84,7 @@ export default {
/>
</div>
<gl-tooltip :target="() => $refs.iconElementXL">
- <span v-html="stateTitle"></span>
+ <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span>
</gl-tooltip>
<gl-icon
v-if="confidential"
@@ -111,7 +110,7 @@ export default {
class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
>
<gl-tooltip :target="() => this.$refs.iconElement">
- <span v-html="stateTitle"></span>
+ <span v-html="stateTitle /* eslint-disable-line vue/no-v-html */"></span>
</gl-tooltip>
<span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
itemPath
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 9ea48050079..77730ada9bb 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlIcon } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
@@ -15,6 +14,10 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import MarkdownHeader from './header.vue';
import MarkdownToolbar from './toolbar.vue';
+function cleanUpLine(content) {
+ return unescape(stripHtml(content).replace(/\\n/g, '%br').replace(/\n/g, ''));
+}
+
export default {
components: {
GfmAutocomplete,
@@ -129,7 +132,7 @@ export default {
return text;
}
- return unescape(stripHtml(richText).replace(/\n/g, ''));
+ return cleanUpLine(richText);
})
.join('\\n');
}
@@ -141,7 +144,7 @@ export default {
return text;
}
- return unescape(stripHtml(richText).replace(/\n/g, ''));
+ return cleanUpLine(richText);
}
return '';
@@ -272,6 +275,7 @@ export default {
:can-suggest="canSuggest"
:show-suggest-popover="showSuggestPopover"
:suggestion-start-index="suggestionsStartIndex"
+ data-testid="markdownHeader"
@preview-markdown="showPreviewTab"
@write-markdown="showWriteTab"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
@@ -319,14 +323,20 @@ export default {
v-show="previewMarkdown"
ref="markdown-preview"
class="js-vue-md-preview md md-preview-holder"
- v-html="markdownPreview"
+ v-html="markdownPreview /* eslint-disable-line vue/no-v-html */"
></div>
</template>
<template v-if="previewMarkdown && !markdownPreviewLoading">
- <div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
+ <div
+ v-if="referencedCommands"
+ class="referenced-commands"
+ v-html="referencedCommands /* eslint-disable-line vue/no-v-html */"
+ ></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
<gl-icon name="warning-solid" />
- <span v-html="addMultipleToDiscussionWarning"></span>
+ <span
+ v-html="addMultipleToDiscussionWarning /* eslint-disable-line vue/no-v-html */"
+ ></span>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 065d9b1b5dd..5fdef0b1a23 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -39,7 +39,8 @@ export default {
},
defaultCommitMessage: {
type: String,
- required: true,
+ required: false,
+ default: null,
},
inapplicableReason: {
type: String,
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 7112295fa57..912aa8ce294 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -75,7 +75,7 @@ export default {
variant="link"
:track-experiment="$options.inviteMembersInComment"
:trigger-source="$options.inviteMembersInComment"
- data-track-event="comment_invite_click"
+ data-track-action="comment_invite_click"
/>
<span class="uploading-progress-container hide">
<gl-icon name="media" />
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 ad6f6e0e2e3..0b302f22062 100644
--- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlLink, GlIcon } from '@gitlab/ui';
import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
@@ -92,7 +91,9 @@ export default {
<gl-icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
- <span v-html="confidentialAndLockedDiscussionText"></span>
+ <span
+ v-html="confidentialAndLockedDiscussionText /* eslint-disable-line vue/no-v-html */"
+ ></span>
{{
__("People without permission will never get a notification and won't be able to comment.")
}}
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index c3d861d74bc..755e6f1f224 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -1,6 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
-
/**
* Common component to render a system note, icon and user information.
*
@@ -97,6 +95,9 @@ export default {
methods: {
...mapActions(['fetchDescriptionVersion', 'softDeleteDescriptionVersion']),
},
+ safeHtmlConfig: {
+ ADD_TAGS: ['use'], // to support icon SVGs
+ },
};
</script>
@@ -106,7 +107,7 @@ export default {
:class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
- <div class="timeline-icon" v-html="iconHtml"></div>
+ <div v-safe-html:[$options.safeHtmlConfig]="iconHtml" class="timeline-icon"></div>
<div class="timeline-content">
<div class="note-header">
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
diff --git a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
index 8a67754993d..6867b5a75e3 100644
--- a/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue
@@ -1,5 +1,12 @@
<script>
-import { GlAlert, GlBadge, GlPagination, GlTab, GlTabs } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlBadge,
+ GlPagination,
+ GlTab,
+ GlTabs,
+ GlSafeHtmlDirective as SafeHtml,
+} from '@gitlab/ui';
import Api from '~/api';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
@@ -20,6 +27,9 @@ export default {
GlTab,
FilteredSearchBar,
},
+ directives: {
+ SafeHtml,
+ },
inject: {
projectPath: {
default: '',
@@ -265,8 +275,7 @@ export default {
<template>
<div class="incident-management-list">
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="$emit('error-alert-dismissed')">
- <!-- eslint-disable-next-line vue/no-v-html -->
- <p v-html="serverErrorMessage || i18n.errorMsg"></p>
+ <p v-safe-html="serverErrorMessage || i18n.errorMsg"></p>
</gl-alert>
<div
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
index 69f43c9e464..36d3696ec36 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable vue/no-v-html */
import { GlButton, GlIcon } from '@gitlab/ui';
import { isString } from 'lodash';
import highlight from '~/lib/utils/highlight';
@@ -61,7 +60,7 @@ export default {
<div
:title="project.name"
class="js-project-name text-truncate"
- v-html="highlightedProjectName"
+ v-html="highlightedProjectName /* eslint-disable-line vue/no-v-html */"
></div>
</div>
</gl-button>
diff --git a/app/assets/javascripts/vue_shared/components/registry/title_area.vue b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
index c63d91b78d3..4b21ec0330a 100644
--- a/app/assets/javascripts/vue_shared/components/registry/title_area.vue
+++ b/app/assets/javascripts/vue_shared/components/registry/title_area.vue
@@ -1,5 +1,6 @@
<script>
import { GlAvatar, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui';
+import { isEqual } from 'lodash';
export default {
name: 'TitleArea',
@@ -36,13 +37,21 @@ export default {
metadataSlots: [],
};
},
- async mounted() {
- const METADATA_PREFIX = 'metadata-';
- this.metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX));
+ mounted() {
+ this.recalculateMetadataSlots();
+ },
+ updated() {
+ this.recalculateMetadataSlots();
+ },
+ methods: {
+ recalculateMetadataSlots() {
+ const METADATA_PREFIX = 'metadata-';
+ const metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX));
- // we need to wait for next tick to ensure that dynamic names slots are picked up
- await this.$nextTick();
- this.metadataSlots = Object.keys(this.$slots).filter((k) => k.startsWith(METADATA_PREFIX));
+ if (!isEqual(metadataSlots, this.metadataSlots)) {
+ this.metadataSlots = metadataSlots;
+ }
+ },
},
};
</script>
diff --git a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
index f21dea468cb..57cc25caa25 100644
--- a/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue
@@ -1,5 +1,6 @@
<script>
import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
+import awsCloudFormationImageUrl from 'images/aws-cloud-formation.png';
import ExperimentTracking from '~/experimentation/experiment_tracking';
import { getBaseURL, objectToQuery } from '~/lib/utils/url_utility';
import { __, s__ } from '~/locale';
@@ -22,6 +23,11 @@ export default {
type: String,
required: true,
},
+ imgSrc: {
+ type: String,
+ required: false,
+ default: awsCloudFormationImageUrl,
+ },
},
methods: {
easyButtonUrl(easyButton) {
@@ -76,7 +82,7 @@ export default {
<img
:title="easyButton.stackName"
:alt="easyButton.stackName"
- src="/assets/aws-cloud-formation.png"
+ :src="imgSrc"
width="46"
height="46"
class="gl-mt-2 gl-mr-5 gl-mb-6"
diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
new file mode 100644
index 00000000000..5242743ad30
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.stories.js
@@ -0,0 +1,26 @@
+import SettingsBlock from './settings_block.vue';
+
+export default {
+ component: SettingsBlock,
+ title: 'vue_shared/components/settings/settings_block',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { SettingsBlock },
+ props: Object.keys(argTypes),
+ template: `
+ <settings-block v-bind="$props">
+ <template #title>Settings section title</template>
+ <template #description>Settings section description</template>
+ <template #default>
+ <p>Content</p>
+ <p>More content</p>
+ <p>Content</p>
+ <p>More content...</p>
+ <p>Content</p>
+ </template>
+ </settings-block>
+ `,
+});
+
+export const Default = Template.bind({});
diff --git a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
index 92ae4575c52..e75fedbb1d7 100644
--- a/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
+++ b/app/assets/javascripts/vue_shared/components/settings/settings_block.vue
@@ -1,5 +1,7 @@
<script>
import { GlButton } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
import { __ } from '~/locale';
export default {
@@ -15,35 +17,99 @@ export default {
default: false,
required: false,
},
+ collapsible: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
},
data() {
return {
- sectionExpanded: false,
+ // Non-collapsible sections should always be expanded.
+ // For collapsible sections, fall back to defaultExpanded.
+ sectionExpanded: !this.collapsible || this.defaultExpanded,
};
},
computed: {
- expanded() {
- return this.defaultExpanded || this.sectionExpanded;
- },
toggleText() {
- return this.expanded ? __('Collapse') : __('Expand');
+ const { collapseText, expandText } = this.$options.i18n;
+ return this.sectionExpanded ? collapseText : expandText;
+ },
+ settingsContentId() {
+ return uniqueId('settings_content_');
},
+ settingsLabelId() {
+ return uniqueId('settings_label_');
+ },
+ toggleButtonAriaLabel() {
+ const { collapseAriaLabel, expandAriaLabel } = this.$options.i18n;
+ return this.sectionExpanded ? collapseAriaLabel : expandAriaLabel;
+ },
+ ariaExpanded() {
+ return String(this.sectionExpanded);
+ },
+ },
+ methods: {
+ toggleSectionExpanded() {
+ this.sectionExpanded = !this.sectionExpanded;
+
+ if (this.sectionExpanded) {
+ this.$refs.settingsContent.focus();
+ }
+ },
+ },
+ i18n: {
+ collapseText: __('Collapse'),
+ expandText: __('Expand'),
+ collapseAriaLabel: __('Collapse settings section'),
+ expandAriaLabel: __('Expand settings section'),
},
};
</script>
<template>
- <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded }">
+ <section class="settings" :class="{ 'no-animate': !slideAnimated, expanded: sectionExpanded }">
<div class="settings-header">
- <h4><slot name="title"></slot></h4>
- <gl-button @click="sectionExpanded = !sectionExpanded">
+ <h4>
+ <span
+ v-if="collapsible"
+ :id="settingsLabelId"
+ role="button"
+ tabindex="0"
+ class="gl-cursor-pointer"
+ :aria-controls="settingsContentId"
+ :aria-expanded="ariaExpanded"
+ data-testid="section-title-button"
+ @click="toggleSectionExpanded"
+ @keydown.enter.space="toggleSectionExpanded"
+ >
+ <slot name="title"></slot>
+ </span>
+ <template v-else>
+ <slot name="title"></slot>
+ </template>
+ </h4>
+ <gl-button
+ v-if="collapsible"
+ :aria-controls="settingsContentId"
+ :aria-expanded="ariaExpanded"
+ :aria-label="toggleButtonAriaLabel"
+ @click="toggleSectionExpanded"
+ >
{{ toggleText }}
</gl-button>
<p>
<slot name="description"></slot>
</p>
</div>
- <div class="settings-content">
+ <div
+ :id="settingsContentId"
+ ref="settingsContent"
+ :aria-labelledby="settingsLabelId"
+ tabindex="-1"
+ role="region"
+ class="settings-content"
+ >
<slot></slot>
</div>
</section>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index 46ccb9470e5..35ac9ef8565 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -1,5 +1,6 @@
<script>
import { GlLabel } from '@gitlab/ui';
+import { sortBy } from 'lodash';
import { mapState } from 'vuex';
import { isScopedLabel } from '~/lib/utils/common_utils';
@@ -23,6 +24,9 @@ export default {
'labelsFilterBasePath',
'labelsFilterParam',
]),
+ sortedSelectedLabels() {
+ return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1));
+ },
},
methods: {
labelFilterUrl(label) {
@@ -47,7 +51,7 @@ export default {
<span v-if="!selectedLabels.length" class="text-secondary">
<slot></slot>
</span>
- <template v-for="label in selectedLabels" v-else>
+ <template v-for="label in sortedSelectedLabels" v-else>
<gl-label
:key="label.id"
data-qa-selector="selected_label_content"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
index e8fdf4bb0c2..dd40add6376 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
@@ -56,7 +56,7 @@ export default {
const labelLink = h(
GlLink,
{
- class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal',
+ class: 'gl-display-flex gl-align-items-center label-item gl-text-body',
on: {
click: () => {
listeners.clickLabel(label);
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue
deleted file mode 100644
index 60111210f5d..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue
+++ /dev/null
@@ -1,42 +0,0 @@
-<script>
-import { GlButton, GlIcon } from '@gitlab/ui';
-import { mapActions, mapGetters } from 'vuex';
-
-export default {
- components: {
- GlButton,
- GlIcon,
- },
- computed: {
- ...mapGetters([
- 'dropdownButtonText',
- 'isDropdownVariantStandalone',
- 'isDropdownVariantEmbedded',
- ]),
- },
- methods: {
- ...mapActions(['toggleDropdownContents']),
- handleButtonClick(e) {
- if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
- this.toggleDropdownContents();
- }
-
- if (this.isDropdownVariantStandalone) {
- e.stopPropagation();
- }
- },
- },
-};
-</script>
-
-<template>
- <gl-button
- class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
- @click="handleButtonClick"
- >
- <span class="dropdown-toggle-text gl-pointer-events-none flex-fill">
- {{ dropdownButtonText }}
- </span>
- <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" />
- </gl-button>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
index 6694e349b6e..0fcc67c0ffa 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -1,22 +1,21 @@
<script>
-import { GlButton } from '@gitlab/ui';
-import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
+import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils';
export default {
components: {
DropdownContentsLabelsView,
DropdownContentsCreateView,
GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLink,
},
+ inject: ['allowLabelCreate', 'labelsManagePath'],
props: {
- renderOnTop: {
- type: Boolean,
- required: false,
- default: false,
- },
labelsCreateTitle: {
type: String,
required: true,
@@ -33,6 +32,10 @@ export default {
type: String,
required: true,
},
+ dropdownButtonText: {
+ type: String,
+ required: true,
+ },
footerCreateLabelTitle: {
type: String,
required: true,
@@ -41,70 +44,105 @@ export default {
type: String,
required: true,
},
+ variant: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showDropdownContentsCreateView: false,
+ };
},
computed: {
- ...mapState(['showDropdownContentsCreateView']),
- ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
dropdownContentsView() {
if (this.showDropdownContentsCreateView) {
return 'dropdown-contents-create-view';
}
return 'dropdown-contents-labels-view';
},
- directionStyle() {
- const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem';
- return this.renderOnTop ? { bottom } : {};
- },
dropdownTitle() {
return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
},
+ showDropdownFooter() {
+ return (
+ !this.showDropdownContentsCreateView &&
+ (this.isDropdownVariantSidebar(this.variant) ||
+ this.isDropdownVariantEmbedded(this.variant))
+ );
+ },
},
methods: {
- ...mapActions(['toggleDropdownContentsCreateView', 'toggleDropdownContents']),
+ showDropdown() {
+ this.$refs.dropdown.show();
+ },
+ toggleDropdownContentsCreateView() {
+ this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView;
+ },
+ toggleDropdownContent() {
+ this.toggleDropdownContentsCreateView();
+ // Required to recalculate dropdown position as its size changes
+ this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate();
+ },
+ isDropdownVariantSidebar,
+ isDropdownVariantEmbedded,
},
};
</script>
<template>
- <div
- class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute"
+ <gl-dropdown
+ ref="dropdown"
+ :text="dropdownButtonText"
+ class="gl-w-full gl-mt-2"
data-qa-selector="labels_dropdown_content"
- :style="directionStyle"
>
- <div
- v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
- class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
- data-testid="dropdown-title"
- >
- <gl-button
- v-if="showDropdownContentsCreateView"
- :aria-label="__('Go back')"
- variant="link"
- size="small"
- class="js-btn-back dropdown-header-button p-0"
- icon="arrow-left"
- @click="toggleDropdownContentsCreateView"
- />
- <span class="flex-grow-1">{{ dropdownTitle }}</span>
- <gl-button
- :aria-label="__('Close')"
- variant="link"
- size="small"
- class="dropdown-header-button gl-p-0!"
- icon="close"
- @click="toggleDropdownContents"
- />
- </div>
+ <template #header>
+ <div
+ v-if="isDropdownVariantSidebar(variant) || isDropdownVariantEmbedded(variant)"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ >
+ <gl-button
+ v-if="showDropdownContentsCreateView"
+ :aria-label="__('Go back')"
+ variant="link"
+ size="small"
+ class="js-btn-back dropdown-header-button gl-p-0"
+ icon="arrow-left"
+ data-testid="go-back-button"
+ @click.stop="toggleDropdownContent"
+ />
+ <span class="gl-flex-grow-1">{{ dropdownTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ @click="$emit('closeDropdown')"
+ />
+ </div>
+ </template>
<component
:is="dropdownContentsView"
:selected-labels="selectedLabels"
:allow-multiselect="allowMultiselect"
- :labels-list-title="labelsListTitle"
- :footer-create-label-title="footerCreateLabelTitle"
- :footer-manage-label-title="footerManageLabelTitle"
@hideCreateView="toggleDropdownContentsCreateView"
- @closeDropdown="$emit('closeDropdown', $event)"
- @toggleDropdownContentsCreateView="toggleDropdownContentsCreateView"
+ @setLabels="$emit('setLabels', $event)"
/>
- </div>
+ <template #footer>
+ <div v-if="showDropdownFooter" data-testid="dropdown-footer">
+ <gl-dropdown-item
+ v-if="allowLabelCreate"
+ data-testid="create-label-button"
+ @click.native.capture.stop="toggleDropdownContent"
+ >
+ {{ footerCreateLabelTitle }}
+ </gl-dropdown-item>
+ <gl-dropdown-item :href="labelsManagePath" @click.native.capture.stop>
+ {{ footerManageLabelTitle }}
+ </gl-dropdown-item>
+ </div>
+ </template>
+ </gl-dropdown>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
index 4651e7a1576..2e31b386fdd 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -1,8 +1,10 @@
<script>
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import produce from 'immer';
import createFlash from '~/flash';
import { __ } from '~/locale';
import createLabelMutation from './graphql/create_label.mutation.graphql';
+import projectLabelsQuery from './graphql/project_labels.query.graphql';
const errorMessage = __('Error creating label.');
@@ -47,6 +49,25 @@ export default {
handleColorClick(color) {
this.selectedColor = this.getColorCode(color);
},
+ updateLabelsInCache(store, label) {
+ const sourceData = store.readQuery({
+ query: projectLabelsQuery,
+ variables: { fullPath: this.projectPath, searchTerm: '' },
+ });
+
+ const collator = new Intl.Collator('en');
+ const data = produce(sourceData, (draftData) => {
+ const { nodes } = draftData.workspace.labels;
+ nodes.push(label);
+ nodes.sort((a, b) => collator.compare(a.title, b.title));
+ });
+
+ store.writeQuery({
+ query: projectLabelsQuery,
+ variables: { fullPath: this.projectPath, searchTerm: '' },
+ data,
+ });
+ },
async createLabel() {
this.labelCreateInProgress = true;
try {
@@ -59,6 +80,14 @@ export default {
color: this.selectedColor,
projectPath: this.projectPath,
},
+ update: (
+ store,
+ {
+ data: {
+ labelCreate: { label },
+ },
+ },
+ ) => this.updateLabelsInCache(store, label),
});
if (labelCreate.errors.length) {
createFlash({ message: errorMessage });
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
index ffa37424c2c..857367a0721 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -1,24 +1,23 @@
<script>
-import { GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import { __ } from '~/locale';
-import { DropdownVariant } from './constants';
import projectLabelsQuery from './graphql/project_labels.query.graphql';
import LabelItem from './label_item.vue';
export default {
components: {
+ GlDropdownForm,
+ GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
- GlLink,
LabelItem,
},
- inject: ['projectPath', 'allowLabelCreate', 'labelsManagePath', 'variant'],
+ inject: ['projectPath'],
props: {
selectedLabels: {
type: Array,
@@ -28,24 +27,11 @@ export default {
type: Boolean,
required: true,
},
- labelsListTitle: {
- type: String,
- required: true,
- },
- footerCreateLabelTitle: {
- type: String,
- required: true,
- },
- footerManageLabelTitle: {
- type: String,
- required: true,
- },
},
data() {
return {
searchKey: '',
labels: [],
- currentHighlightItem: -1,
localSelectedLabels: [...this.selectedLabels],
};
},
@@ -74,12 +60,6 @@ export default {
},
},
computed: {
- isDropdownVariantSidebar() {
- return this.variant === DropdownVariant.Sidebar;
- },
- isDropdownVariantEmbedded() {
- return this.variant === DropdownVariant.Embedded;
- },
labelsFetchInProgress() {
return this.$apollo.queries.labels.loading;
},
@@ -98,21 +78,11 @@ export default {
return Boolean(this.searchKey) && this.visibleLabels.length === 0;
},
},
- watch: {
- searchKey(value) {
- // When there is search string present
- // and there are matching results,
- // highlight first item by default.
- if (value && this.visibleLabels.length) {
- this.currentHighlightItem = 0;
- }
- },
- },
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
beforeDestroy() {
- this.$emit('closeDropdown', this.localSelectedLabels);
+ this.$emit('setLabels', this.localSelectedLabels);
this.debouncedSearchKeyUpdate.cancel();
},
methods: {
@@ -150,33 +120,6 @@ export default {
});
}
},
- /**
- * This method enables keyboard navigation support for
- * the dropdown.
- */
- handleKeyDown(e) {
- if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) {
- this.currentHighlightItem -= 1;
- } else if (
- e.keyCode === DOWN_KEY_CODE &&
- this.currentHighlightItem < this.visibleLabels.length - 1
- ) {
- this.currentHighlightItem += 1;
- } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
- this.updateSelectedLabels(this.visibleLabels[this.currentHighlightItem]);
- this.searchKey = '';
- } else if (e.keyCode === ESC_KEY_CODE) {
- this.$emit('closeDropdown', this.localSelectedLabels);
- }
-
- if (e.keyCode !== ESC_KEY_CODE) {
- // Scroll the list only after highlighting
- // styles are rendered completely.
- this.$nextTick(() => {
- this.scrollIntoViewIfNeeded();
- });
- }
- },
handleLabelClick(label) {
this.updateSelectedLabels(label);
if (!this.allowMultiselect) {
@@ -191,69 +134,41 @@ export default {
</script>
<template>
- <div
- class="labels-select-contents-list js-labels-list"
- data-testid="dropdown-wrapper"
- @keydown="handleKeyDown"
- >
- <div class="dropdown-input" @click.stop="() => {}">
- <gl-search-box-by-type
- ref="searchInput"
- :value="searchKey"
- :disabled="labelsFetchInProgress"
- data-qa-selector="dropdown_input_field"
- data-testid="dropdown-input-field"
- @input="debouncedSearchKeyUpdate"
- />
- </div>
- <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content">
+ <gl-dropdown-form class="labels-select-contents-list js-labels-list">
+ <gl-search-box-by-type
+ ref="searchInput"
+ :value="searchKey"
+ :disabled="labelsFetchInProgress"
+ data-qa-selector="dropdown_input_field"
+ data-testid="dropdown-input-field"
+ @input="debouncedSearchKeyUpdate"
+ />
+ <div ref="labelsListContainer" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full"
size="md"
/>
- <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word" data-testid="labels-list">
- <label-item
- v-for="(label, index) in visibleLabels"
+ <template v-else>
+ <gl-dropdown-item
+ v-for="label in visibleLabels"
:key="label.id"
- :label="label"
- :is-label-set="isLabelSelected(label)"
- :highlight="index === currentHighlightItem"
- @clickLabel="handleLabelClick(label)"
- />
- <li
+ :is-checked="isLabelSelected(label)"
+ :is-check-centered="true"
+ :is-check-item="true"
+ data-testid="labels-list"
+ @click.native.capture.stop="handleLabelClick(label)"
+ >
+ <label-item :label="label" />
+ </gl-dropdown-item>
+ <gl-dropdown-item
v-show="showNoMatchingResultsMessage"
class="gl-p-3 gl-text-center"
data-testid="no-results"
>
{{ __('No matching results') }}
- </li>
- </ul>
- </div>
- <div
- v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
- class="dropdown-footer"
- data-testid="dropdown-footer"
- >
- <ul class="list-unstyled">
- <li v-if="allowLabelCreate">
- <gl-link
- class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item"
- data-testid="create-label-button"
- @click="$emit('toggleDropdownContentsCreateView')"
- >
- {{ footerCreateLabelTitle }}
- </gl-link>
- </li>
- <li>
- <gl-link
- :href="labelsManagePath"
- class="gl-display-flex gl-flex-direction-row gl-w-full gl-overflow-break-word label-item"
- >
- {{ footerManageLabelTitle }}
- </gl-link>
- </li>
- </ul>
+ </gl-dropdown-item>
+ </template>
</div>
- </div>
+ </gl-dropdown-form>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
deleted file mode 100644
index 46edfa1c42a..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-<script>
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-
-export default {
- components: {
- GlButton,
- GlLoadingIcon,
- },
- props: {
- labelsSelectInProgress: {
- type: Boolean,
- required: true,
- },
- },
- computed: {
- ...mapState(['allowLabelEdit', 'labelsFetchInProgress']),
- },
- methods: {
- ...mapActions(['toggleDropdownContents']),
- },
-};
-</script>
-
-<template>
- <div class="title hide-collapsed gl-mb-3">
- {{ __('Labels') }}
- <template v-if="allowLabelEdit">
- <gl-loading-icon v-show="labelsSelectInProgress" size="sm" inline />
- <gl-button
- category="tertiary"
- size="small"
- class="float-right js-sidebar-dropdown-toggle gl-mr-n2"
- data-qa-selector="labels_edit_button"
- @click="toggleDropdownContents"
- >{{ __('Edit') }}</gl-button
- >
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
index 58a940bca3b..71d3d87cce5 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
@@ -1,5 +1,6 @@
<script>
import { GlLabel } from '@gitlab/ui';
+import { sortBy } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
@@ -7,6 +8,7 @@ export default {
components: {
GlLabel,
},
+ inject: ['allowScopedLabels'],
props: {
disableLabels: {
type: Boolean,
@@ -21,10 +23,6 @@ export default {
type: Boolean,
required: true,
},
- allowScopedLabels: {
- type: Boolean,
- required: true,
- },
labelsFilterBasePath: {
type: String,
required: true,
@@ -34,6 +32,11 @@ export default {
required: true,
},
},
+ computed: {
+ sortedSelectedLabels() {
+ return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1));
+ },
+ },
methods: {
labelFilterUrl(label) {
return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent(
@@ -63,7 +66,7 @@ export default {
</span>
<template v-else>
<gl-label
- v-for="label in selectedLabels"
+ v-for="label in sortedSelectedLabels"
:key="label.id"
data-qa-selector="selected_label_content"
:data-qa-label-name="label.title"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
index 9aa4f5d165e..eb478645a03 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql
@@ -6,9 +6,7 @@ mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPa
id
color
description
- descriptionHtml
title
- textColor
}
errors
}
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
index e8fdf4bb0c2..f27f0b4e34c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
@@ -1,82 +1,21 @@
<script>
-import { GlLink, GlIcon } from '@gitlab/ui';
-
export default {
- functional: true,
props: {
label: {
type: Object,
required: true,
},
- isLabelSet: {
- type: Boolean,
- required: true,
- },
- highlight: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- render(h, { props, listeners }) {
- const { label, highlight, isLabelSet } = props;
-
- const labelColorBox = h('span', {
- class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
- style: {
- backgroundColor: label.color,
- },
- attrs: {
- 'data-testid': 'label-color-box',
- },
- });
-
- const checkedIcon = h(GlIcon, {
- class: {
- 'gl-mr-3 gl-flex-shrink-0': true,
- hidden: !isLabelSet,
- },
- props: {
- name: 'mobile-issue-close',
- },
- });
-
- const noIcon = h('span', {
- class: {
- 'gl-mr-5 gl-pr-3': true,
- hidden: isLabelSet,
- },
- attrs: {
- 'data-testid': 'no-icon',
- },
- });
-
- const labelTitle = h('span', label.title);
-
- const labelLink = h(
- GlLink,
- {
- class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal',
- on: {
- click: () => {
- listeners.clickLabel(label);
- },
- },
- },
- [noIcon, checkedIcon, labelColorBox, labelTitle],
- );
-
- return h(
- 'li',
- {
- class: {
- 'gl-display-block': true,
- 'gl-text-left': true,
- 'is-focused': highlight,
- },
- },
- [labelLink],
- );
},
};
</script>
+
+<template>
+ <div>
+ <span
+ class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3"
+ :style="{ 'background-color': label.color }"
+ data-testid="label-color-box"
+ ></span>
+ <span>{{ label.title }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
index 0499dfe468f..3c834770563 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -1,57 +1,40 @@
<script>
-import $ from 'jquery';
import Vue from 'vue';
-import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
-import { isInViewport } from '~/lib/utils/common_utils';
+import Vuex from 'vuex';
import { __ } from '~/locale';
-
+import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { DropdownVariant } from './constants';
-import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
-import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import issueLabelsQuery from './graphql/issue_labels.query.graphql';
-import labelsSelectModule from './store';
+import {
+ isDropdownVariantSidebar,
+ isDropdownVariantStandalone,
+ isDropdownVariantEmbedded,
+} from './utils';
Vue.use(Vuex);
export default {
- store: new Vuex.Store(labelsSelectModule()),
components: {
- DropdownTitle,
DropdownValue,
- DropdownButton,
DropdownContents,
DropdownValueCollapsed,
+ SidebarEditableItem,
},
- inject: ['iid', 'projectPath'],
+ inject: ['iid', 'projectPath', 'allowLabelEdit'],
props: {
allowLabelRemove: {
type: Boolean,
required: false,
default: false,
},
- allowLabelEdit: {
- type: Boolean,
- required: false,
- default: false,
- },
- allowLabelCreate: {
- type: Boolean,
- required: false,
- default: false,
- },
allowMultiselect: {
type: Boolean,
required: false,
default: false,
},
- allowScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
variant: {
type: String,
required: false,
@@ -67,16 +50,6 @@ export default {
required: false,
default: false,
},
- labelsFetchPath: {
- type: String,
- required: false,
- default: '',
- },
- labelsManagePath: {
- type: String,
- required: false,
- default: '',
- },
labelsFilterBasePath: {
type: String,
required: false,
@@ -138,149 +111,25 @@ export default {
},
},
},
- computed: {
- ...mapState(['showDropdownButton', 'showDropdownContents']),
- ...mapGetters([
- 'isDropdownVariantSidebar',
- 'isDropdownVariantStandalone',
- 'isDropdownVariantEmbedded',
- ]),
- dropdownButtonVisible() {
- return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
- },
- },
- watch: {
- selectedLabels(selectedLabels) {
- this.setInitialState({
- selectedLabels,
- });
- },
- showDropdownContents(showDropdownContents) {
- this.setContentIsOnViewport(showDropdownContents);
- },
- isEditing(newVal) {
- if (newVal) {
- this.toggleDropdownContents();
- }
- },
- },
- mounted() {
- this.setInitialState({
- variant: this.variant,
- allowLabelRemove: this.allowLabelRemove,
- allowLabelEdit: this.allowLabelEdit,
- allowLabelCreate: this.allowLabelCreate,
- allowMultiselect: this.allowMultiselect,
- allowScopedLabels: this.allowScopedLabels,
- dropdownButtonText: this.dropdownButtonText,
- selectedLabels: this.selectedLabels,
- labelsFetchPath: this.labelsFetchPath,
- labelsManagePath: this.labelsManagePath,
- labelsFilterBasePath: this.labelsFilterBasePath,
- labelsFilterParam: this.labelsFilterParam,
- labelsListTitle: this.labelsListTitle,
- footerCreateLabelTitle: this.footerCreateLabelTitle,
- footerManageLabelTitle: this.footerManageLabelTitle,
- });
-
- this.$store.subscribeAction({
- after: this.handleVuexActionDispatch,
- });
-
- document.addEventListener('mousedown', this.handleDocumentMousedown);
- document.addEventListener('click', this.handleDocumentClick);
- },
- beforeDestroy() {
- document.removeEventListener('mousedown', this.handleDocumentMousedown);
- document.removeEventListener('click', this.handleDocumentClick);
- },
methods: {
- ...mapActions(['setInitialState', 'toggleDropdownContents']),
- /**
- * This method stores a mousedown event's target.
- * Required by the click listener because the click
- * event itself has no reference to this element.
- */
- handleDocumentMousedown({ target }) {
- this.mousedownTarget = target;
- },
- /**
- * This method listens for document-wide click event
- * and toggle dropdown if user clicks anywhere outside
- * the dropdown while dropdown is visible.
- */
- handleDocumentClick({ target }) {
- // We also perform the toggle exception check for the
- // last mousedown event's target to avoid hiding the
- // box when the mousedown happened inside the box and
- // only the mouseup did not.
- if (
- this.showDropdownContents &&
- !this.preventDropdownToggleOnClick(target) &&
- !this.preventDropdownToggleOnClick(this.mousedownTarget)
- ) {
- this.toggleDropdownContents();
- }
- },
- /**
- * This method checks whether a given click target
- * should prevent the dropdown from being toggled.
- */
- preventDropdownToggleOnClick(target) {
- // This approach of element detection is needed
- // as the dropdown wrapper is not using `GlDropdown` as
- // it will also require us to use `BDropdownForm`
- // which is yet to be implemented in GitLab UI.
- const hasExceptionClass = [
- 'js-dropdown-button',
- 'js-btn-cancel-create',
- 'js-sidebar-dropdown-toggle',
- ].some(
- (className) =>
- target?.classList.contains(className) ||
- target?.parentElement?.classList.contains(className),
- );
-
- const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
- (className) => $(target).parents(className).length,
- );
-
- const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
-
- const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
-
- return (
- hasExceptionClass ||
- hasExceptionParent ||
- isInDropdownButtonCollapsed ||
- isInDropdownContents
- );
- },
handleDropdownClose(labels) {
- // Only emit label updates if there are any labels to update
- // on UI.
- if (this.showDropdownContents) {
- this.toggleDropdownContents();
- }
if (labels.length) this.$emit('updateSelectedLabels', labels);
this.$emit('onDropdownClose');
},
+ collapseDropdown() {
+ this.$refs.editable.collapse();
+ },
handleCollapsedValueClick() {
this.$emit('toggleCollapse');
},
- setContentIsOnViewport(showDropdownContents) {
- if (!showDropdownContents) {
- this.contentIsOnViewport = true;
-
- return;
- }
-
+ showDropdown() {
this.$nextTick(() => {
- if (this.$refs.dropdownContents) {
- this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el);
- }
+ this.$refs.dropdownContents.showDropdown();
});
},
+ isDropdownVariantSidebar,
+ isDropdownVariantStandalone,
+ isDropdownVariantEmbedded,
},
};
</script>
@@ -289,58 +138,63 @@ export default {
<div
class="labels-select-wrapper position-relative"
:class="{
- 'is-standalone': isDropdownVariantStandalone,
- 'is-embedded': isDropdownVariantEmbedded,
+ 'is-standalone': isDropdownVariantStandalone(variant),
+ 'is-embedded': isDropdownVariantEmbedded(variant),
}"
>
- <template v-if="isDropdownVariantSidebar">
+ <template v-if="isDropdownVariantSidebar(variant)">
<dropdown-value-collapsed
ref="dropdownButtonCollapsed"
:labels="issueLabels"
@onValueClick="handleCollapsedValueClick"
/>
- <dropdown-title
- :allow-label-edit="allowLabelEdit"
- :labels-select-in-progress="labelsSelectInProgress"
- />
- <dropdown-value
- :disable-labels="labelsSelectInProgress"
- :selected-labels="issueLabels"
- :allow-label-remove="allowLabelRemove"
- :allow-scoped-labels="allowScopedLabels"
- :labels-filter-base-path="labelsFilterBasePath"
- :labels-filter-param="labelsFilterParam"
- @onLabelRemove="$emit('onLabelRemove', $event)"
+ <sidebar-editable-item
+ ref="editable"
+ :title="__('Labels')"
+ :loading="labelsSelectInProgress"
+ :can-edit="allowLabelEdit"
+ @open="showDropdown"
>
- <slot></slot>
- </dropdown-value>
- <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
- <dropdown-contents
- v-if="dropdownButtonVisible && showDropdownContents"
- ref="dropdownContents"
- :allow-multiselect="allowMultiselect"
- :labels-list-title="labelsListTitle"
- :footer-create-label-title="footerCreateLabelTitle"
- :footer-manage-label-title="footerManageLabelTitle"
- :render-on-top="!contentIsOnViewport"
- :labels-create-title="labelsCreateTitle"
- :selected-labels="selectedLabels"
- @closeDropdown="handleDropdownClose"
- />
- </template>
- <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
- <dropdown-button v-show="dropdownButtonVisible" />
- <dropdown-contents
- v-if="dropdownButtonVisible && showDropdownContents"
- ref="dropdownContents"
- :allow-multiselect="allowMultiselect"
- :labels-list-title="labelsListTitle"
- :footer-create-label-title="footerCreateLabelTitle"
- :footer-manage-label-title="footerManageLabelTitle"
- :render-on-top="!contentIsOnViewport"
- :selected-labels="selectedLabels"
- @closeDropdown="handleDropdownClose"
- />
+ <template #collapsed>
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ :selected-labels="issueLabels"
+ :allow-label-remove="allowLabelRemove"
+ :labels-filter-base-path="labelsFilterBasePath"
+ :labels-filter-param="labelsFilterParam"
+ @onLabelRemove="$emit('onLabelRemove', $event)"
+ >
+ <slot></slot>
+ </dropdown-value>
+ </template>
+ <template #default="{ edit }">
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ :selected-labels="issueLabels"
+ :allow-label-remove="allowLabelRemove"
+ :labels-filter-base-path="labelsFilterBasePath"
+ :labels-filter-param="labelsFilterParam"
+ class="gl-mb-2"
+ @onLabelRemove="$emit('onLabelRemove', $event)"
+ >
+ <slot></slot>
+ </dropdown-value>
+ <dropdown-contents
+ v-if="edit"
+ ref="dropdownContents"
+ :dropdown-button-text="dropdownButtonText"
+ :allow-multiselect="allowMultiselect"
+ :labels-list-title="labelsListTitle"
+ :footer-create-label-title="footerCreateLabelTitle"
+ :footer-manage-label-title="footerManageLabelTitle"
+ :labels-create-title="labelsCreateTitle"
+ :selected-labels="selectedLabels"
+ :variant="variant"
+ @closeDropdown="collapseDropdown"
+ @setLabels="handleDropdownClose"
+ />
+ </template>
+ </sidebar-editable-item>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
deleted file mode 100644
index b3d4a204a81..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import * as types from './mutation_types';
-
-export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props);
-
-export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON);
-export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS);
-
-export const toggleDropdownContentsCreateView = ({ commit }) =>
- commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW);
-
-export const updateSelectedLabels = ({ commit }, labels) =>
- commit(types.UPDATE_SELECTED_LABELS, { labels });
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js
deleted file mode 100644
index d14f96720b7..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import { __, s__, sprintf } from '~/locale';
-import { DropdownVariant } from '../constants';
-
-/**
- * Returns string representing current labels
- * selection on dropdown button.
- *
- * @param {object} state
- */
-export const dropdownButtonText = (state, getters) => {
- const selectedLabels = getters.isDropdownVariantSidebar
- ? state.labels.filter((label) => label.set)
- : state.selectedLabels;
-
- if (!selectedLabels.length) {
- return state.dropdownButtonText || __('Label');
- } else if (selectedLabels.length > 1) {
- return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
- firstLabelName: selectedLabels[0].title,
- remainingLabelCount: selectedLabels.length - 1,
- });
- }
- return selectedLabels[0].title;
-};
-
-/**
- * Returns array containing only label IDs from
- * selectedLabels array.
- * @param {object} state
- */
-export const selectedLabelsList = (state) => state.selectedLabels.map((label) => label.id);
-
-/**
- * Returns boolean representing whether dropdown variant
- * is `sidebar`
- * @param {object} state
- */
-export const isDropdownVariantSidebar = (state) => state.variant === DropdownVariant.Sidebar;
-
-/**
- * Returns boolean representing whether dropdown variant
- * is `standalone`
- * @param {object} state
- */
-export const isDropdownVariantStandalone = (state) => state.variant === DropdownVariant.Standalone;
-
-/**
- * Returns boolean representing whether dropdown variant
- * is `embedded`
- * @param {object} state
- */
-export const isDropdownVariantEmbedded = (state) => state.variant === DropdownVariant.Embedded;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js
deleted file mode 100644
index 5f61cb732c8..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-import state from './state';
-
-export default () => ({
- namespaced: true,
- state: state(),
- actions,
- getters,
- mutations,
-});
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
deleted file mode 100644
index bd71c3b85f1..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
-
-export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
-export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
-
-export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
-
-export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
deleted file mode 100644
index 45ec4d7ae04..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils';
-import { DropdownVariant } from '../constants';
-import * as types from './mutation_types';
-
-export default {
- [types.SET_INITIAL_STATE](state, props) {
- Object.assign(state, { ...props });
- },
-
- [types.TOGGLE_DROPDOWN_BUTTON](state) {
- state.showDropdownButton = !state.showDropdownButton;
- },
-
- [types.TOGGLE_DROPDOWN_CONTENTS](state) {
- if (state.variant === DropdownVariant.Sidebar) {
- state.showDropdownButton = !state.showDropdownButton;
- }
- state.showDropdownContents = !state.showDropdownContents;
- // Ensure that Create View is hidden by default
- // when dropdown contents are revealed.
- if (state.showDropdownContents) {
- state.showDropdownContentsCreateView = false;
- }
- },
-
- [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) {
- state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView;
- },
- [types.UPDATE_SELECTED_LABELS](state, { labels }) {
- // Find the label to update from all the labels
- // and change `set` prop value to represent their current state.
- const labelId = labels.pop()?.id;
- const candidateLabel = state.labels.find((label) => labelId === label.id);
- if (candidateLabel) {
- candidateLabel.touched = true;
- candidateLabel.set = !candidateLabel.set;
- }
-
- if (isScopedLabel(candidateLabel)) {
- const scopedBase = scopedLabelKey(candidateLabel);
- const currentActiveScopedLabel = state.labels.find(
- ({ title }) => title.indexOf(scopedBase) === 0 && title !== candidateLabel.title,
- );
-
- if (currentActiveScopedLabel) {
- currentActiveScopedLabel.set = false;
- }
- }
- },
-};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
deleted file mode 100644
index 220bab05ed2..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
+++ /dev/null
@@ -1,28 +0,0 @@
-export default () => ({
- // Initial Data
- labels: [],
- selectedLabels: [],
- labelsListTitle: '',
- footerCreateLabelTitle: '',
- footerManageLabelTitle: '',
- dropdownButtonText: '',
-
- // Paths
- namespace: '',
- labelsFetchPath: '',
- labelsFilterBasePath: '',
-
- // UI Flags
- variant: '',
- allowLabelRemove: false,
- allowLabelCreate: false,
- allowLabelEdit: false,
- allowScopedLabels: false,
- allowMultiselect: false,
- showDropdownButton: false,
- showDropdownContents: false,
- showDropdownContentsCreateView: false,
- labelsFetchInProgress: false,
- labelCreateInProgress: false,
- selectedLabelsUpdated: false,
-});
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js
new file mode 100644
index 00000000000..b5cd946a189
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/utils.js
@@ -0,0 +1,22 @@
+import { DropdownVariant } from './constants';
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `sidebar`
+ * @param {string} variant
+ */
+export const isDropdownVariantSidebar = (variant) => variant === DropdownVariant.Sidebar;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `standalone`
+ * @param {string} variant
+ */
+export const isDropdownVariantStandalone = (variant) => variant === DropdownVariant.Standalone;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `embedded`
+ * @param {string} variant
+ */
+export const isDropdownVariantEmbedded = (variant) => variant === DropdownVariant.Embedded;
diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js
new file mode 100644
index 00000000000..00aa5519ec6
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.stories.js
@@ -0,0 +1,38 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+import '@gitlab/ui/dist/utility_classes.css';
+import UsageGraph from './usage_graph.vue';
+
+export default {
+ component: UsageGraph,
+ title: 'vue_shared/components/storage_counter/usage_graph',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { UsageGraph },
+ props: Object.keys(argTypes),
+ template: '<usage-graph v-bind="$props" />',
+});
+
+export const Default = Template.bind({});
+Default.argTypes = {
+ rootStorageStatistics: {
+ description: 'The statistics object with all its fields',
+ type: { name: 'object', required: true },
+ defaultValue: {
+ buildArtifactsSize: 400000,
+ pipelineArtifactsSize: 38000,
+ lfsObjectsSize: 4800000,
+ packagesSize: 3800000,
+ repositorySize: 39000000,
+ snippetsSize: 2000112,
+ storageSize: 39930000,
+ uploadsSize: 7000,
+ wikiSize: 300000,
+ },
+ },
+ limit: {
+ description:
+ 'When a limit is set, users will see how much of their storage usage (limit) is used. In case the limit is 0 or the current usage exceeds the limit, it just renders the distribution',
+ defaultValue: 0,
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue
new file mode 100644
index 00000000000..c33d065ff4b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/storage_counter/usage_graph.vue
@@ -0,0 +1,148 @@
+<script>
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ rootStorageStatistics: {
+ required: true,
+ type: Object,
+ },
+ limit: {
+ required: true,
+ type: Number,
+ },
+ },
+ computed: {
+ storageTypes() {
+ const {
+ buildArtifactsSize,
+ pipelineArtifactsSize,
+ lfsObjectsSize,
+ packagesSize,
+ repositorySize,
+ storageSize,
+ wikiSize,
+ snippetsSize,
+ uploadsSize,
+ } = this.rootStorageStatistics;
+ const artifactsSize = buildArtifactsSize + pipelineArtifactsSize;
+
+ if (storageSize === 0) {
+ return null;
+ }
+
+ return [
+ {
+ name: s__('UsageQuota|Repositories'),
+ style: this.usageStyle(this.barRatio(repositorySize)),
+ class: 'gl-bg-data-viz-blue-500',
+ size: repositorySize,
+ },
+ {
+ name: s__('UsageQuota|LFS Objects'),
+ style: this.usageStyle(this.barRatio(lfsObjectsSize)),
+ class: 'gl-bg-data-viz-orange-600',
+ size: lfsObjectsSize,
+ },
+ {
+ name: s__('UsageQuota|Packages'),
+ style: this.usageStyle(this.barRatio(packagesSize)),
+ class: 'gl-bg-data-viz-aqua-500',
+ size: packagesSize,
+ },
+ {
+ name: s__('UsageQuota|Artifacts'),
+ style: this.usageStyle(this.barRatio(artifactsSize)),
+ class: 'gl-bg-data-viz-green-600',
+ size: artifactsSize,
+ tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'),
+ },
+ {
+ name: s__('UsageQuota|Wikis'),
+ style: this.usageStyle(this.barRatio(wikiSize)),
+ class: 'gl-bg-data-viz-magenta-500',
+ size: wikiSize,
+ },
+ {
+ name: s__('UsageQuota|Snippets'),
+ style: this.usageStyle(this.barRatio(snippetsSize)),
+ class: 'gl-bg-data-viz-orange-800',
+ size: snippetsSize,
+ },
+ {
+ name: s__('UsageQuota|Uploads'),
+ style: this.usageStyle(this.barRatio(uploadsSize)),
+ class: 'gl-bg-data-viz-aqua-700',
+ size: uploadsSize,
+ },
+ ]
+ .filter((data) => data.size !== 0)
+ .sort((a, b) => b.size - a.size);
+ },
+ },
+ methods: {
+ formatSize(size) {
+ return numberToHumanSize(size);
+ },
+ usageStyle(ratio) {
+ return { flex: ratio };
+ },
+ barRatio(size) {
+ let max = this.rootStorageStatistics.storageSize;
+
+ if (this.limit !== 0 && max <= this.limit) {
+ max = this.limit;
+ }
+
+ return size / max;
+ },
+ },
+};
+</script>
+<template>
+ <div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100">
+ <div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex">
+ <div
+ v-for="storageType in storageTypes"
+ :key="storageType.name"
+ class="storage-type-usage gl-h-full gl-display-inline-block"
+ :class="storageType.class"
+ :style="storageType.style"
+ data-testid="storage-type-usage"
+ ></div>
+ </div>
+ <div class="row py-0">
+ <div
+ v-for="storageType in storageTypes"
+ :key="storageType.name"
+ class="col-md-auto gl-display-flex gl-align-items-center"
+ data-testid="storage-type-legend"
+ >
+ <div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div>
+ <span class="gl-mr-2 gl-font-weight-bold gl-font-sm">
+ {{ storageType.name }}
+ </span>
+ <span class="gl-text-gray-500 gl-font-sm">
+ {{ formatSize(storageType.size) }}
+ </span>
+ <span
+ v-if="storageType.tooltip"
+ v-gl-tooltip
+ :title="storageType.tooltip"
+ :aria-label="storageType.tooltip"
+ class="gl-ml-2"
+ >
+ <gl-icon name="question" :size="12" />
+ </span>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
index b9ee74d6a03..42334d80eec 100644
--- a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue
@@ -66,7 +66,7 @@ export default {
};
</script>
<template>
- <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!">
+ <gl-dropdown :text="selectedTimezoneLabel" block lazy menu-class="gl-w-full!" v-bind="$attrs">
<gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus />
<gl-dropdown-item
v-for="timezone in filteredResults"
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index f387f8ca128..74616763f8f 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,6 +1,12 @@
<script>
-/* eslint-disable vue/no-v-html */
-import { GlPopover, GlLink, GlSkeletonLoader, GlIcon } from '@gitlab/ui';
+import {
+ GlPopover,
+ GlLink,
+ GlSkeletonLoader,
+ GlIcon,
+ GlSafeHtmlDirective,
+ GlSprintf,
+} from '@gitlab/ui';
import UserNameWithStatus from '~/sidebar/components/assignees/user_name_with_status.vue';
import { glEmojiTag } from '../../../emoji';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
@@ -17,6 +23,10 @@ export default {
GlSkeletonLoader,
UserAvatarImage,
UserNameWithStatus,
+ GlSprintf,
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
},
props: {
target: {
@@ -50,6 +60,7 @@ export default {
return this.user?.status?.availability || '';
},
},
+ safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
};
</script>
@@ -83,7 +94,7 @@ export default {
<div class="gl-text-gray-500">
<div v-if="user.bio" class="gl-display-flex gl-mb-2">
<gl-icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" />
- <span ref="bio" class="gl-ml-2 gl-overflow-hidden" v-html="user.bioHtml"></span>
+ <span ref="bio" class="gl-ml-2 gl-overflow-hidden">{{ user.bio }}</span>
</div>
<div v-if="user.workInformation" class="gl-display-flex gl-mb-2">
<gl-icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" />
@@ -95,12 +106,14 @@ export default {
<span class="gl-ml-2">{{ user.location }}</span>
</div>
<div v-if="statusHtml" class="js-user-status gl-mt-3">
- <span v-html="statusHtml"></span>
+ <span v-safe-html:[$options.safeHtmlConfig]="statusHtml"></span>
</div>
<div v-if="user.bot" class="gl-text-blue-500">
<gl-icon name="question" />
<gl-link data-testid="user-popover-bot-docs-link" :href="user.websiteUrl">
- {{ sprintf(__('Learn more about %{username}'), { username: user.name }) }}
+ <gl-sprintf :message="__('Learn more about %{username}')">
+ <template #username>{{ user.name }}</template>
+ </gl-sprintf>
</gl-link>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/security_reports/constants.js b/app/assets/javascripts/vue_shared/security_reports/constants.js
index 4a50dfbd82f..b024e92bd0e 100644
--- a/app/assets/javascripts/vue_shared/security_reports/constants.js
+++ b/app/assets/javascripts/vue_shared/security_reports/constants.js
@@ -24,6 +24,7 @@ export const REPORT_TYPE_DEPENDENCY_SCANNING = 'dependency_scanning';
export const REPORT_TYPE_CONTAINER_SCANNING = 'container_scanning';
export const REPORT_TYPE_CLUSTER_IMAGE_SCANNING = 'cluster_image_scanning';
export const REPORT_TYPE_COVERAGE_FUZZING = 'coverage_fuzzing';
+export const REPORT_TYPE_CORPUS_MANAGEMENT = 'corpus_management';
export const REPORT_TYPE_LICENSE_COMPLIANCE = 'license_scanning';
export const REPORT_TYPE_API_FUZZING = 'api_fuzzing';
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
index e860e3af924..c1b3f546431 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/sast/state.js
@@ -1,7 +1,5 @@
export default () => ({
paths: {
- head: null,
- base: null,
diffEndpoint: null,
},
diff --git a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
index e860e3af924..c1b3f546431 100644
--- a/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
+++ b/app/assets/javascripts/vue_shared/security_reports/store/modules/secret_detection/state.js
@@ -1,7 +1,5 @@
export default () => ({
paths: {
- head: null,
- base: null,
diffEndpoint: null,
},
diff --git a/app/assets/javascripts/webpack_non_compiled_placeholder.js b/app/assets/javascripts/webpack_non_compiled_placeholder.js
index 8cd1d2eb2ca..55ac2f0be6a 100644
--- a/app/assets/javascripts/webpack_non_compiled_placeholder.js
+++ b/app/assets/javascripts/webpack_non_compiled_placeholder.js
@@ -8,7 +8,7 @@ Object.assign(div.style, {
left: 0,
'z-index': 100000,
background: 'rgba(0,0,0,0.9)',
- 'font-size': '25px',
+ 'font-size': '20px',
'font-family': 'monospace',
color: 'white',
padding: '2.5em',
@@ -16,9 +16,23 @@ Object.assign(div.style, {
});
div.innerHTML = `
-<h1 style="color:white">🧙 Webpack is doing its magic 🧙</h1>
-<p>If you use Hot Module reloading, the page will reload in a few seconds.</p>
-<p>If you do not use Hot Module reloading, please <a href="">reload the page manually in a few seconds</a></p>
+<!-- https://github.com/webpack/media/blob/master/logo/icon-square-big.svg -->
+<svg height="50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200">
+ <path fill="#FFF" d="M600 0l530.3 300v600L600 1200 69.7 900V300z"/>
+ <path fill="#8ED6FB" class="st1" d="M1035.6 879.3l-418.1 236.5V931.6L878 788.3l157.6 91zm28.6-25.9V358.8l-153 88.3V765l153 88.4zm-901.5 25.9l418.1 236.5V931.6L320.3 788.3l-157.6 91zm-28.6-25.9V358.8l153 88.3V765l-153 88.4zM152 326.8L580.8 84.2v178.1L306.1 413.4l-2.1 1.2-152-87.8zm894.3 0L617.5 84.2v178.1l274.7 151.1 2.1 1.2 152-87.8z"/>
+ <path fill="#1C78C0" d="M580.8 889.7l-257-141.3v-280l257 148.4v272.9zm36.7 0l257-141.3v-280l-257 148.4v272.9zm-18.3-283.6zM341.2 436l258-141.9 258 141.9-258 149-258-149z"/>
+</svg>
+
+<h1 style="color:white">✨ webpack is compiling frontend assets ✨</h1>
+<p>
+ To reduce GDK memory consumption, incremental on-demand compiling is on by default.<br />
+ You can disable this within gdk.yml.
+ Learn more <a href="https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/configuration.md#webpack-settings">here</a>.
+</p>
+<p>
+ If you have live_reload enabled, the page will reload automatically when complete.<br />
+ Otherwise, please <a href="">reload the page manually in a few seconds</a>
+</p>
`;
document.body.append(div);
diff --git a/app/assets/javascripts/whats_new/components/feature.vue b/app/assets/javascripts/whats_new/components/feature.vue
index 11096b08032..a93bda326de 100644
--- a/app/assets/javascripts/whats_new/components/feature.vue
+++ b/app/assets/javascripts/whats_new/components/feature.vue
@@ -40,7 +40,7 @@ export default {
:href="feature.url"
target="_blank"
class="gl-display-block"
- data-track-event="click_whats_new_item"
+ data-track-action="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>
@@ -55,7 +55,7 @@ export default {
:href="feature.url"
target="_blank"
class="whats-new-item-title-link gl-display-block gl-mt-4 gl-mb-1"
- data-track-event="click_whats_new_item"
+ data-track-action="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>
@@ -79,7 +79,7 @@ export default {
<gl-button
:href="feature.url"
target="_blank"
- data-track-event="click_whats_new_item"
+ data-track-action="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>
diff --git a/app/assets/javascripts/work_items/components/app.vue b/app/assets/javascripts/work_items/components/app.vue
new file mode 100644
index 00000000000..93de17d1e43
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/app.vue
@@ -0,0 +1,9 @@
+<script>
+export default {
+ name: 'WorkItemRoot',
+};
+</script>
+
+<template>
+ <div></div>
+</template>
diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql
new file mode 100644
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql
diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js
new file mode 100644
index 00000000000..a635d43776d
--- /dev/null
+++ b/app/assets/javascripts/work_items/index.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import App from './components/app.vue';
+
+export const initWorkItemsRoot = () => {
+ const el = document.querySelector('#js-work-items');
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};
diff --git a/app/assets/stylesheets/application_dark.scss b/app/assets/stylesheets/application_dark.scss
index 7d6ccc40278..dae0cd72a8f 100644
--- a/app/assets/stylesheets/application_dark.scss
+++ b/app/assets/stylesheets/application_dark.scss
@@ -44,6 +44,17 @@ body.gl-dark {
}
}
+ .header-search {
+ background-color: var(--gray-100) !important;
+ box-shadow: inset 0 0 0 1px var(--border-color) !important;
+
+ &:active,
+ &:hover {
+ background-color: var(--gray-100) !important;
+ box-shadow: inset 0 0 0 1px var(--blue-200) !important;
+ }
+ }
+
.search {
form {
background-color: var(--gray-100);
diff --git a/app/assets/stylesheets/components/content_editor.scss b/app/assets/stylesheets/components/content_editor.scss
index 64abf5574fa..a013d971efb 100644
--- a/app/assets/stylesheets/components/content_editor.scss
+++ b/app/assets/stylesheets/components/content_editor.scss
@@ -1,8 +1,10 @@
.ProseMirror {
td,
th,
- li {
- :only-child {
+ li,
+ dd,
+ dt {
+ :first-child {
margin-bottom: 0 !important;
}
}
@@ -34,6 +36,20 @@
}
}
}
+
+ .dl-content {
+ width: 100%;
+
+ > li {
+ list-style-type: none;
+ margin-left: $gl-spacing-scale-5;
+
+ &.dl-term {
+ margin: 0;
+ font-weight: 600;
+ }
+ }
+ }
}
.table-creator-grid-item {
diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss
index f4519841ce3..dc08c816d7d 100644
--- a/app/assets/stylesheets/errors.scss
+++ b/app/assets/stylesheets/errors.scss
@@ -91,6 +91,7 @@ a {
.field {
margin-bottom: 0;
margin-right: 0.5em;
+ flex: 1;
}
}
}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 264373451d5..a0682eabf01 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -154,12 +154,6 @@
}
}
- .cover-desc {
- &.username:last-child {
- padding-bottom: $gl-padding;
- }
- }
-
.cover-controls {
@include media-breakpoint-up(sm) {
position: absolute;
@@ -343,8 +337,6 @@
}
.code-block {
- background: $black;
- color: $gray-darkest;
white-space: pre;
overflow-x: auto;
font-size: 12px;
diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss
index 61a20c7a8fd..568182ad796 100644
--- a/app/assets/stylesheets/framework/diffs.scss
+++ b/app/assets/stylesheets/framework/diffs.scss
@@ -613,7 +613,7 @@ table.code {
grid-template-columns: 1fr 1fr;
}
- &.inline {
+ &.inline-diff-view {
.diff-grid-comments {
display: grid;
grid-template-columns: 1fr;
@@ -682,26 +682,6 @@ table.code {
max-height: 50vh;
}
-.diff-stats-summary-toggler {
- padding: 0;
- background-color: transparent;
- border: 0;
- color: $blue-600;
- font-weight: $gl-font-weight-bold;
-
- &:hover,
- &:focus {
- outline: none;
- color: $blue-800;
- }
-
- .caret-icon {
- position: relative;
- top: 2px;
- left: -1px;
- }
-}
-
// Mobile
@media (max-width: 480px) {
.diff-title {
@@ -853,21 +833,14 @@ table.code {
.diff-files-changed {
.inline-parallel-buttons {
- position: relative;
+ @include gl-relative;
z-index: 1;
}
- .commit-stat-summary {
- @include media-breakpoint-up(sm) {
- background-color: $white;
- }
- }
-
@include media-breakpoint-up(sm) {
- position: -webkit-sticky;
- position: sticky;
+ @include gl-sticky;
top: $header-height + $mr-tabs-height;
- background-color: $white;
+ @include gl-bg-white;
z-index: 200;
.with-performance-bar & {
@@ -875,14 +848,13 @@ table.code {
}
&.is-stuck {
- padding-top: 0;
- padding-bottom: 0;
+ @include gl-py-0;
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
.diff-stats-additions-deletions-expanded,
.inline-parallel-buttons {
- display: none !important;
+ @include gl-display-none;
}
}
}
@@ -890,12 +862,13 @@ table.code {
@include media-breakpoint-up(lg) {
&.is-stuck {
.diff-stats-additions-deletions-collapsed {
- display: block !important;
+ @include gl-display-block;
}
}
}
}
+
.diff-file-changes {
max-width: 560px;
width: 100%;
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index f76101d92b1..5dd71cec8d1 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -386,15 +386,6 @@
}
}
}
-
- .boards-add-list > .btn {
- text-align: left;
-
- > svg {
- position: absolute;
- right: 6px;
- }
- }
}
.droplab-dropdown .dropdown-menu .filter-dropdown-item {
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 222e10f51ad..0aeb7208c59 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -36,6 +36,7 @@
}
}
+.ci-status-icon-notification,
.ci-status-icon-preparing,
.ci-status-icon-created,
.ci-status-icon-skipped,
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 603b05efe10..aeb3bb2286f 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -41,6 +41,12 @@
}
}
+ .media-container {
+ display: inline-flex;
+ flex-direction: column;
+ margin-bottom: $gl-spacing-scale-2;
+ }
+
img:not(.emoji) {
margin: 0 0 8px;
}
@@ -549,17 +555,12 @@
margin: 0;
font-size: $gl-font-size-small;
}
+ }
- ul.dropdown-menu {
- margin-top: 4px;
- margin-bottom: 24px;
- padding: 8px 0;
-
- li {
- margin: 0;
- padding: 0 1px;
- }
- }
+ .gl-new-dropdown-item {
+ margin: 0;
+ padding: 0;
+ line-height: 1rem;
}
/* AsciiDoc(tor) built-in alignment roles */
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 726f8e28efe..099dfa28b9f 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -505,7 +505,7 @@ $line-removed-dark: #fac5cd !default;
* would hide other layers (selected text, matching brackets).
*
* When the transparent colors get layered on white background, they create their
- * full opacity counterparts (computed with https://stackoverflow.com/a/12228643/606571):
+ * full opacity counterparts:
*
* - white + $line-added-transparent = $line-added
* - white + $line-added-transparent + $line-added-dark-transparent = $line-added-dark
diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss
index 9d889f111dd..3220510775c 100644
--- a/app/assets/stylesheets/mailer.scss
+++ b/app/assets/stylesheets/mailer.scss
@@ -1,7 +1,6 @@
@import 'framework/variables';
// Do not use 3-letter hex codes, bgcolor vs css background-color is problematic in emails
-// See https://stackoverflow.com/questions/28551981/why-are-3-digit-hex-color-code-values-interpreted-differently-in-internet-explor
//
// stylelint-disable color-hex-length
diff --git a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
index 2f8602a212d..8794acd3c78 100644
--- a/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
+++ b/app/assets/stylesheets/page_bundles/_pipeline_mixins.scss
@@ -1,15 +1,3 @@
-@mixin flat-connector-before($length: 44px) {
- &::before {
- content: '';
- position: absolute;
- top: 48%;
- left: -$length;
- border-top: 2px solid var(--border-color, $border-color);
- width: $length;
- height: 1px;
- }
-}
-
@mixin build-content($border-radius: 30px) {
display: inline-block;
padding: 8px 10px 9px;
diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss
index 10183f774b1..4806f4b054b 100644
--- a/app/assets/stylesheets/page_bundles/boards.scss
+++ b/app/assets/stylesheets/page_bundles/boards.scss
@@ -15,14 +15,6 @@
}
}
-.dropdown-menu-issues-board-new {
- width: 320px;
-
- .dropdown-content {
- max-height: 140px;
- }
-}
-
.issue-board-dropdown-content {
margin: 0;
padding: $gl-padding-4 $gl-padding $gl-padding;
@@ -256,7 +248,8 @@
margin-right: 4px;
}
- .confidential-icon {
+ .confidential-icon,
+ .hidden-icon {
color: var(--orange-500, $orange-500);
cursor: help;
}
@@ -437,6 +430,10 @@
height: $input-height;
}
+.issue-boards-content {
+ isolation: isolate;
+}
+
.issue-boards-content.is-focused {
position: fixed;
width: 100%;
diff --git a/app/assets/stylesheets/page_bundles/cycle_analytics.scss b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
index 2248d95ae24..5d42ece32c9 100644
--- a/app/assets/stylesheets/page_bundles/cycle_analytics.scss
+++ b/app/assets/stylesheets/page_bundles/cycle_analytics.scss
@@ -3,293 +3,4 @@
.cycle-analytics {
margin: 24px auto 0;
position: relative;
-
- .landing {
- margin-top: 0;
-
- .inner-content {
- white-space: normal;
-
- h4,
- p {
- margin: 7px 0 0;
- max-width: 480px;
- padding: 0 $gl-padding;
-
- @include media-breakpoint-down(sm) {
- margin: 0 auto;
- }
- }
- }
-
- .svg-container svg {
- width: 136px;
- height: 136px;
- }
- }
-
- .col-headers {
- ul {
- margin: 0;
- padding: 0;
- }
-
- li {
- line-height: 50px;
- }
- }
-
- .card {
- .content-block {
- padding: 24px 0;
- border-bottom: 0;
- position: relative;
-
- @include media-breakpoint-down(xs) {
- padding: 6px 0 24px;
- }
- }
-
- .column {
- text-align: center;
-
- @include media-breakpoint-down(xs) {
- padding: 15px 0;
- }
-
- .header {
- font-size: 30px;
- line-height: 38px;
- font-weight: $gl-font-weight-normal;
- margin: 0;
- }
-
- .text {
- color: var(--gray-500, $gray-500);
- margin: 0;
- }
-
- &:last-child {
- @include media-breakpoint-down(xs) {
- text-align: center;
- }
- }
- }
- }
-
- .stage-panel-body {
- display: flex;
- flex-wrap: wrap;
- }
-
- .stage-nav,
- .stage-entries {
- display: flex;
- vertical-align: top;
- font-size: $gl-font-size;
- }
-
- .stage-nav {
- width: 40%;
- margin-bottom: 0;
-
- ul {
- padding: 0;
- margin: 0;
- width: 100%;
- }
-
- li {
- list-style-type: none;
- }
-
- .stage-nav-item {
- line-height: 65px;
-
- &.active {
- background: var(--blue-50, $blue-50);
- border-color: var(--blue-300, $blue-300);
- box-shadow: inset 4px 0 0 0 var(--blue-500, $blue-500);
- }
-
- &:hover:not(.active) {
- background-color: var(--gray-10, $gray-10);
- box-shadow: inset 2px 0 0 0 var(--border-color, $border-color);
- cursor: pointer;
- }
-
- .stage-nav-item-cell.stage-name {
- width: 44.5%;
- }
-
- .stage-nav-item-cell.stage-median {
- min-width: 43%;
- }
-
- .stage-empty,
- .not-available {
- color: var(--gray-500, $gray-500);
- }
- }
- }
-
- .stage-panel-container {
- width: 100%;
- overflow: auto;
- }
-
- .stage-panel {
- min-width: 968px;
-
- .card-header {
- padding: 0;
- background-color: transparent;
- }
-
- .events-description {
- line-height: 65px;
- }
-
- .events-info {
- color: var(--gray-500, $gray-500);
- }
- }
-
- .stage-events {
- min-height: 467px;
- }
-
- .stage-event-list {
- margin: 0;
- padding: 0;
- }
-
- .stage-event-item {
- @include clearfix;
- list-style-type: none;
- padding-bottom: $gl-padding;
- margin-bottom: $gl-padding;
- border-bottom: 1px solid var(--gray-50, $gray-50);
-
- &:last-child {
- border-bottom: 0;
- margin-bottom: 0;
- }
-
- .item-details,
- .item-time {
- float: left;
- }
-
- .item-details {
- width: 75%;
- }
-
- .item-title {
- margin: 0 0 2px;
-
- &.issue-title,
- &.commit-title,
- &.merge-request-title {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 100%;
- display: block;
-
- a {
- color: var(--gl-text-color, $gl-text-color);
- }
- }
- }
-
- .item-time {
- width: 25%;
- text-align: right;
- }
-
- .total-time {
- font-size: $cycle-analytics-big-font;
- color: var(--gl-text-color, $gl-text-color);
-
- span {
- color: var(--gl-text-color, $gl-text-color);
- font-size: $gl-font-size;
- }
- }
-
- .issue-date,
- .build-date {
- color: var(--gl-text-color, $gl-text-color);
- }
-
- .mr-link,
- .issue-link,
- .commit-author-link,
- .issue-author-link {
- color: var(--gl-text-color, $gl-text-color);
- }
-
- // Custom CSS for components
- .item-conmmit-component {
- .commit-icon {
- svg {
- display: inline-block;
- width: 20px;
- height: 20px;
- vertical-align: bottom;
- }
- }
- }
-
- .merge-request-branch {
- a {
- max-width: 180px;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- display: inline-block;
- vertical-align: bottom;
- }
- }
- }
-
- // Custom Styles for stage items
- .item-build-component {
- .item-title {
- .icon-build-status {
- float: left;
- margin-right: 5px;
- position: relative;
- top: 2px;
- }
-
- .item-build-name {
- color: var(--gl-text-color, $gl-text-color);
- }
-
- .pipeline-id {
- color: var(--gl-text-color, $gl-text-color);
- padding: 0 3px 0 0;
- }
-
- .ref-name {
- color: var(--gray-900, $gray-900);
- display: inline-block;
- max-width: 180px;
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- line-height: 1.3;
- vertical-align: top;
- }
-
- .commit-sha {
- color: var(--blue-600, $blue-600);
- line-height: 1.3;
- vertical-align: top;
- font-weight: $gl-font-weight-normal;
- }
- }
- }
}
diff --git a/app/assets/stylesheets/page_bundles/escalation_policies.scss b/app/assets/stylesheets/page_bundles/escalation_policies.scss
index f188dde1183..6f3873fea0c 100644
--- a/app/assets/stylesheets/page_bundles/escalation_policies.scss
+++ b/app/assets/stylesheets/page_bundles/escalation_policies.scss
@@ -16,9 +16,6 @@ $stroke-size: 1px;
.right-arrow {
@include gl-relative;
- @include gl-mx-5;
- @include gl-display-inline-block;
- @include gl-vertical-align-middle;
height: $stroke-size;
background-color: var(--gray-900, $gray-900);
min-width: $gl-spacing-scale-7;
@@ -27,7 +24,6 @@ $stroke-size: 1px;
@include gl-absolute;
top: -2*$stroke-size;
left: calc(100% - #{5*$stroke-size});
- @include gl-display-inline-block;
@include gl-p-1;
@include gl-border-solid;
border-width: 0 $stroke-size $stroke-size 0;
@@ -35,3 +31,24 @@ $stroke-size: 1px;
transform: rotate(-45deg);
}
}
+
+.escalation-rule-row {
+ @media (max-width: $breakpoint-lg) {
+ @include gl-flex-wrap;
+ }
+}
+
+.rule-condition {
+ @media (min-width: $breakpoint-lg) {
+ flex-basis: 25%;
+ flex-shrink: 0;
+ }
+
+ @media (max-width: $breakpoint-lg) {
+ @include gl-w-full;
+ }
+}
+
+.rule-action {
+ min-width: 0;
+}
diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss
index 6a20ff3b3fa..28354b83856 100644
--- a/app/assets/stylesheets/page_bundles/merge_requests.scss
+++ b/app/assets/stylesheets/page_bundles/merge_requests.scss
@@ -40,7 +40,7 @@
position: -webkit-sticky;
position: sticky;
- // Unitless zero values are not allowed in calculations https://stackoverflow.com/a/55391061
+ // Unitless zero values are not allowed in calculations
// stylelint-disable-next-line length-zero-no-unit
top: calc(#{$top-pos} + var(--system-header-height, 0px) + var(--performance-bar-height, 0px));
// stylelint-disable-next-line length-zero-no-unit
diff --git a/app/assets/stylesheets/page_bundles/new_namespace.scss b/app/assets/stylesheets/page_bundles/new_namespace.scss
index 189f010bdb2..37a1231ec6b 100644
--- a/app/assets/stylesheets/page_bundles/new_namespace.scss
+++ b/app/assets/stylesheets/page_bundles/new_namespace.scss
@@ -17,10 +17,10 @@ $new-namespace-panel-height: 240px;
.new-namespace-panel {
&:hover {
- background-color: $gray-10;
+ background-color: var(--gray-50, $gray-10);
}
- color: $purple-700;
+ color: var(--purple-700, $purple-700);
min-height: $new-namespace-panel-height;
text-align: center;
@include media-breakpoint-up(lg) {
diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss
index c9171eb4fc7..206c2eb09d0 100644
--- a/app/assets/stylesheets/page_bundles/pipeline.scss
+++ b/app/assets/stylesheets/page_bundles/pipeline.scss
@@ -120,17 +120,10 @@
}
}
-.pipeline-tab-content {
- display: flex;
- width: 100%;
- min-height: $dropdown-max-height-lg;
- 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.
+// These are single-value classes to use with utility-class style CSS.
+// They are here to still access a variable or because they use magic values.
+// scoped to the graph. Do not add other styles.
.gl-pipeline-min-h {
min-height: $dropdown-max-height-lg;
}
@@ -147,22 +140,6 @@
padding-right: 120px;
}
-.gl-build-content {
- display: inline-block;
- padding: 8px 10px 9px;
- width: 100%;
- border: 1px solid var(--border-color, $border-color);
- border-radius: 30px;
- background-color: var(--white, $white);
-
- &:hover,
- &:focus {
- background-color: var(--gray-50, $gray-50);
- border: 1px solid $dropdown-toggle-active-border-color;
- color: var(--gl-text-color, $gl-text-color);
- }
-}
-
.gl-ci-action-icon-container {
position: absolute;
right: 5px;
@@ -180,259 +157,6 @@
}
}
-// Pipeline graph, used at
-// app/assets/javascripts/pipelines/components/graph/graph_component.vue
-.pipeline-graph {
- white-space: nowrap;
- transition: max-height 0.3s, padding 0.3s;
-
- .stage-column-list,
- .builds-container > ul {
- padding: 0;
- }
-
- a {
- text-decoration: none;
- color: var(--gl-text-color, $gl-text-color);
- }
-
- svg {
- vertical-align: middle;
- }
-
- .stage-column {
- display: inline-block;
- vertical-align: top;
-
- &.left-margin {
- &:not(:first-child) {
- margin-left: 44px;
-
- .left-connector {
- @include flat-connector-before;
- }
- }
- }
-
- &.no-margin {
- margin: 0;
- }
-
- li {
- list-style: none;
- }
-
- // when downstream pipelines are present, the last stage isn't the last column
- &:last-child:not(.has-downstream) {
- .build {
- // Remove right connecting horizontal line from first build in last stage
- &:first-child::after {
- border: 0;
- }
- // Remove right curved connectors from all builds in last stage
- &:not(:first-child)::after {
- border: 0;
- }
- // Remove opposite curve
- .curve::before {
- display: none;
- }
- }
- }
-
- // when upstream pipelines are present, the first stage isn't the first column
- &:first-child:not(.has-upstream) {
- .build {
- // Remove left curved connectors from all builds in first stage
- &:not(:first-child)::before {
- border: 0;
- }
- // Remove opposite curve
- .curve::after {
- display: none;
- }
- }
- }
-
- // Curve first child connecting lines in opposite direction
- .curve {
- display: none;
-
- &::before,
- &::after {
- content: '';
- width: 21px;
- height: 25px;
- position: absolute;
- top: -31px;
- border-top: 2px solid var(--border-color, $border-color);
- }
-
- &::after {
- left: -44px;
- border-right: 2px solid var(--border-color, $border-color);
- border-radius: 0 20px;
- }
-
- &::before {
- right: -44px;
- border-left: 2px solid var(--border-color, $border-color);
- border-radius: 20px 0 0;
- }
- }
- }
-
- .stage-name {
- margin: 0 0 15px 10px;
- font-weight: $gl-font-weight-bold;
- width: 176px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 2.2em;
- }
-
- .build {
- position: relative;
- width: 186px;
- margin-bottom: 10px;
- white-space: normal;
-
- .ci-job-dropdown-container {
- // override dropdown.scss
- .dropdown-menu li button {
- padding: 0;
- text-align: center;
- }
- }
-
- .ci-status-icon svg {
- height: 24px;
- width: 24px;
- }
-
- .dropdown-menu-toggle {
- background-color: transparent;
- border: 0;
- padding: 0;
-
- &:focus {
- outline: none;
- }
- }
-
- .build-content {
- @include build-content();
- }
-
- .ci-job-dropdown-container:hover .build-content,
- a.build-content:hover,
- button.build-content:hover {
- background-color: var(--gray-100, $gray-100);
- border: 1px solid $dropdown-toggle-active-border-color;
- }
-
- // Connect first build in each stage with right horizontal line
- &:first-child {
- &::after {
- content: '';
- position: absolute;
- top: 48%;
- right: -48px;
- border-top: 2px solid var(--border-color, $border-color);
- width: 48px;
- height: 1px;
- }
- }
-
- // Connect each build (except for first) with curved lines
- &:not(:first-child) {
- &::after,
- &::before {
- content: '';
- top: -49px;
- position: absolute;
- border-bottom: 2px solid var(--border-color, $border-color);
- width: 25px;
- height: 69px;
- }
-
- // Right connecting curves
- &::after {
- right: -25px;
- border-right: 2px solid var(--border-color, $border-color);
- border-radius: 0 0 20px;
- }
-
- // Left connecting curves
- &::before {
- left: -25px;
- border-left: 2px solid var(--border-color, $border-color);
- border-radius: 0 0 0 20px;
- }
- }
-
- // Connect second build to first build with smaller curved line
- &:nth-child(2) {
- &::after,
- &::before {
- height: 29px;
- top: -9px;
- }
-
- .curve {
- display: block;
- }
- }
- }
-
- .ci-action-icon-container {
- position: absolute;
- right: 5px;
- top: 50%;
- transform: translateY(-50%);
-
- // Action Icons in big pipeline-graph nodes
- &.ci-action-icon-wrapper {
- height: 30px;
- width: 30px;
- border-radius: 100%;
- display: block;
- padding: 0;
- line-height: 0;
-
- svg {
- fill: var(--gray-500, $gray-500);
- }
-
- .gl-spinner {
- top: 2px;
- }
-
- &.play {
- svg {
- left: 1px;
- top: 1px;
- }
- }
- }
- }
-
- .stage-action svg {
- left: 1px;
- top: -2px;
- }
-}
-
-// Triggers the dropdown in the big pipeline graph
-.dropdown-counter-badge {
- font-weight: 100;
- font-size: 15px;
- position: absolute;
- right: 13px;
- top: 8px;
-}
-
.split-report-section {
border-bottom: 1px solid var(--gray-50, $gray-50);
@@ -480,34 +204,6 @@
left: 100%;
top: -10px;
box-shadow: 0 1px 5px $black-transparent;
-
- /**
- * Top arrow in the dropdown in the big pipeline graph
- */
- &::before,
- &::after {
- content: '';
- display: inline-block;
- position: absolute;
- width: 0;
- height: 0;
- border-color: transparent;
- border-style: solid;
- top: 18px;
- }
-
- &::before {
- left: -6px;
- margin-top: 3px;
- border-width: 7px 5px 7px 0;
- border-right-color: var(--border-color, $border-color);
- }
-
- &::after {
- left: -5px;
- border-width: 10px 7px 10px 0;
- border-right-color: var(--white, $white);
- }
}
.codequality-report {
diff --git a/app/assets/stylesheets/page_bundles/reports.scss b/app/assets/stylesheets/page_bundles/reports.scss
index ce91988cb8a..d0748779f47 100644
--- a/app/assets/stylesheets/page_bundles/reports.scss
+++ b/app/assets/stylesheets/page_bundles/reports.scss
@@ -49,11 +49,6 @@
display: flex;
}
-.is-dismissed .report-block-list-issue-description,
-.is-dismissed .vulnerability-name-button {
- text-decoration: line-through;
-}
-
.report-block-list-issue-description-text::after {
content: '\00a0';
}
diff --git a/app/assets/stylesheets/page_bundles/signup.scss b/app/assets/stylesheets/page_bundles/signup.scss
index d6c3a3ff5da..57e5d2411d1 100644
--- a/app/assets/stylesheets/page_bundles/signup.scss
+++ b/app/assets/stylesheets/page_bundles/signup.scss
@@ -39,36 +39,6 @@
}
}
-.signup-page[data-page^='registrations:experience_levels'] {
- $card-shadow-color: rgba(var(--black, $black), 0.2);
-
- .page-wrap {
- background-color: var(--white, $white);
- }
-
- .card-deck {
- max-width: 828px;
- }
-
- .card {
- transition: box-shadow 0.3s ease-in-out;
- }
-
- .card:hover {
- box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color;
- }
-
- @media (min-width: $breakpoint-sm) {
- .card-deck .card {
- margin: 0 $gl-spacing-scale-3;
- }
- }
-
- .stretched-link:hover {
- text-decoration: none;
- }
-}
-
.edit-profile {
max-width: 460px;
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 5173aeb824e..bc4dbf695cf 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -250,6 +250,10 @@
.commit-row-description {
display: none;
flex: 1;
+
+ a {
+ color: $blue-600;
+ }
}
&.inline-commit {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index ee97e8af296..94912b1c641 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -7,6 +7,7 @@
text-align: center;
margin-right: $issuable-warning-icon-margin;
line-height: $gl-line-height-24;
+ flex: 0 0 auto;
}
.limit-container-width {
@@ -121,7 +122,9 @@
.right-sidebar {
position: fixed;
top: $header-height;
- bottom: 0;
+ // Default value for CSS var must contain a unit
+ // stylelint-disable-next-line length-zero-no-unit
+ bottom: var(--review-bar-height, 0px);
right: 0;
transition: width $sidebar-transition-duration;
background: $gray-light;
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index b537a46a6f2..773935f4c76 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -96,15 +96,10 @@
}
form {
- width: 48%;
padding: 0;
border: 0;
background: none;
margin-bottom: $gl-padding;
-
- @include media-breakpoint-down(md) {
- width: 100%;
- }
}
.omniauth-btn {
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 34a03a07405..3c0f10eb5cb 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -37,7 +37,7 @@
.note-textarea {
display: block;
- padding: 10px 0;
+ padding: 10px 1px;
color: $gl-text-color;
font-family: $regular-font;
border: 0;
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index b52a3c445b5..de9e0c6f705 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -70,14 +70,6 @@
}
}
-.profile-link-holder {
- display: inline;
-
- a:not(.text-link) {
- text-decoration: none;
- }
-}
-
// Middle dot divider between each element in a list of items.
.middle-dot-divider {
@include middle-dot-divider;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 8f5de73365b..2e6c6a021f8 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -37,6 +37,25 @@ input[type='checkbox']:hover {
0 0 0 1px lighten($dropdown-input-focus-shadow, 20%);
}
+.header-search {
+ width: 320px;
+
+ input,
+ svg {
+ transition: border-color ease-in-out $default-transition-duration,
+ background-color ease-in-out $default-transition-duration;
+ }
+}
+
+.header-search-dropdown-menu {
+ max-height: $dropdown-max-height;
+ top: $header-height;
+}
+
+.header-search-dropdown-content {
+ max-height: $dropdown-max-height;
+}
+
.search {
margin: 0 8px;
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index c6198315606..5765156f26c 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -102,7 +102,6 @@
.tree-table {
margin-bottom: 0;
- table-layout: fixed;
tr {
border-bottom: 1px solid $white-normal;
diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss
index a90751f772e..b7958cdf4a3 100644
--- a/app/assets/stylesheets/startup/startup-dark.scss
+++ b/app/assets/stylesheets/startup/startup-dark.scss
@@ -11,6 +11,7 @@ body.gl-dark {
--green-700: #91d4a8;
--blue-400: #1f75cb;
--orange-400: #ab6100;
+ --purple-100: #2f2a6b;
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
--black: #fff;
@@ -374,6 +375,38 @@ h1 {
.m-auto {
margin: auto !important;
}
+.gl-form-input,
+.gl-form-input.form-control {
+ background-color: #333;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+ "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-size: 0.875rem;
+ line-height: 1rem;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+ height: auto;
+ color: #fafafa;
+ box-shadow: inset 0 0 0 1px #868686;
+ border-style: none;
+ appearance: none;
+ -moz-appearance: none;
+}
+.gl-form-input:disabled,
+.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only,
+.gl-form-input.form-control:disabled,
+.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
+ background-color: #1f1f1f;
+ color: #868686;
+ box-shadow: inset 0 0 0 1px #404040;
+ cursor: not-allowed;
+}
+.gl-form-input::placeholder,
+.gl-form-input.form-control::placeholder {
+ color: #868686;
+}
.gl-button {
display: inline-flex;
}
@@ -1237,7 +1270,7 @@ input {
.nav-sidebar-inner-scroll > div.context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.nav-sidebar-inner-scroll > div.context-header a .avatar-container.rect-avatar {
border-style: none;
@@ -1247,7 +1280,7 @@ input {
a
.avatar-container.rect-avatar
.avatar.s32 {
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items {
margin-top: 0.25rem;
@@ -1261,7 +1294,7 @@ input {
.sidebar-top-level-items .context-header a .avatar-container {
font-weight: 400;
flex: none;
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items .context-header a .avatar-container.rect-avatar {
border-style: none;
@@ -1271,7 +1304,7 @@ input {
a
.avatar-container.rect-avatar
.avatar.s32 {
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.sidebar-top-level-items > li .badge.badge-pill {
border-radius: 0.5rem;
@@ -1409,6 +1442,9 @@ svg.s12 {
svg.s16 {
vertical-align: -3px;
}
+.header-search {
+ width: 320px;
+}
.search {
margin: 0 8px;
}
@@ -1479,7 +1515,7 @@ svg.s16 {
float: left;
margin-right: 16px;
border-radius: 50%;
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(0, 0, 0, 0.08);
}
.avatar.s16,
.avatar-container.s16 {
@@ -1524,7 +1560,7 @@ svg.s16 {
background-color: #660e00;
}
.identicon.bg2 {
- background-color: #f4f0ff;
+ background-color: #232150;
}
.identicon.bg3 {
background-color: #f1f1ff;
@@ -1635,6 +1671,22 @@ body.gl-dark
.notification-dot {
background-color: #fafafa;
}
+body.gl-dark .header-search {
+ background-color: rgba(250, 250, 250, 0.2) !important;
+}
+body.gl-dark .header-search svg {
+ color: rgba(250, 250, 250, 0.8) !important;
+}
+body.gl-dark .header-search input {
+ background-color: transparent;
+ color: rgba(250, 250, 250, 0.8);
+}
+body.gl-dark .header-search input::placeholder {
+ color: rgba(250, 250, 250, 0.8);
+}
+body.gl-dark .header-search input:active::placeholder {
+ color: #fafafa;
+}
body.gl-dark .search form {
background-color: rgba(250, 250, 250, 0.2);
}
@@ -1651,7 +1703,7 @@ body.gl-dark .nav-sidebar li.active > a {
body.gl-dark .nav-sidebar .fly-out-top-item a,
body.gl-dark .nav-sidebar .fly-out-top-item.active a,
body.gl-dark .nav-sidebar .fly-out-top-item .fly-out-top-item-container {
- background-color: #2f2a6b;
+ background-color: var(--purple-100, #e1d8f9);
color: var(--black, #333);
}
body.gl-dark .logo-text svg {
@@ -1668,6 +1720,14 @@ body.gl-dark .navbar-gitlab .navbar-nav li.active > button {
color: var(--gl-text-color);
background-color: var(--gray-200);
}
+body.gl-dark .navbar-gitlab .header-search {
+ background-color: var(--gray-100) !important;
+ box-shadow: inset 0 0 0 1px var(--border-color) !important;
+}
+body.gl-dark .navbar-gitlab .header-search:active {
+ background-color: var(--gray-100) !important;
+ box-shadow: inset 0 0 0 1px var(--blue-200) !important;
+}
body.gl-dark .navbar-gitlab .search form {
background-color: var(--gray-100);
box-shadow: inset 0 0 0 1px var(--border-color);
@@ -1746,6 +1806,17 @@ body.gl-dark {
--indigo-900: #ebebfa;
--indigo-950: #f7f7ff;
--indigo-900-alpha-008: rgba(235, 235, 250, 0.08);
+ --purple-50: #232150;
+ --purple-100: #2f2a6b;
+ --purple-200: #453894;
+ --purple-300: #5943b6;
+ --purple-400: #694cc0;
+ --purple-500: #7b58cf;
+ --purple-600: #9475db;
+ --purple-700: #ac93e6;
+ --purple-800: #cbbbf2;
+ --purple-900: #e1d8f9;
+ --purple-950: #f4f0ff;
--gl-text-color: #fafafa;
--border-color: #4f4f4f;
--white: #333;
diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss
index 0b2d34b6f5d..2c79b819899 100644
--- a/app/assets/stylesheets/startup/startup-general.scss
+++ b/app/assets/stylesheets/startup/startup-general.scss
@@ -355,6 +355,38 @@ h1 {
.m-auto {
margin: auto !important;
}
+.gl-form-input,
+.gl-form-input.form-control {
+ background-color: #fff;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+ "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-size: 0.875rem;
+ line-height: 1rem;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+ height: auto;
+ color: #303030;
+ box-shadow: inset 0 0 0 1px #868686;
+ border-style: none;
+ appearance: none;
+ -moz-appearance: none;
+}
+.gl-form-input:disabled,
+.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only,
+.gl-form-input.form-control:disabled,
+.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
+ background-color: #fafafa;
+ color: #868686;
+ box-shadow: inset 0 0 0 1px #dbdbdb;
+ cursor: not-allowed;
+}
+.gl-form-input::placeholder,
+.gl-form-input.form-control::placeholder {
+ color: #868686;
+}
.gl-button {
display: inline-flex;
}
@@ -1390,6 +1422,9 @@ svg.s12 {
svg.s16 {
vertical-align: -3px;
}
+.header-search {
+ width: 320px;
+}
.search {
margin: 0 8px;
}
diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss
index 070ab36e0b3..013ad3fac87 100644
--- a/app/assets/stylesheets/startup/startup-signin.scss
+++ b/app/assets/stylesheets/startup/startup-signin.scss
@@ -315,9 +315,9 @@ fieldset:disabled a.btn {
-moz-appearance: none;
}
.gl-form-input:disabled,
-.gl-form-input:not(.form-control-plaintext):read-only,
+.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only,
.gl-form-input.form-control:disabled,
-.gl-form-input.form-control:not(.form-control-plaintext):read-only {
+.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only {
background-color: #fafafa;
color: #868686;
box-shadow: inset 0 0 0 1px #dbdbdb;
@@ -634,17 +634,11 @@ svg {
margin: 0;
}
.login-page .omniauth-container form {
- width: 48%;
padding: 0;
border: 0;
background: none;
margin-bottom: 16px;
}
-@media (max-width: 991.98px) {
- .login-page .omniauth-container form {
- width: 100%;
- }
-}
.login-page .omniauth-container .omniauth-btn {
width: 100%;
}
diff --git a/app/assets/stylesheets/themes/_dark.scss b/app/assets/stylesheets/themes/_dark.scss
index 8e1438eaf8a..f12b2ee2591 100644
--- a/app/assets/stylesheets/themes/_dark.scss
+++ b/app/assets/stylesheets/themes/_dark.scss
@@ -72,6 +72,18 @@ $indigo-900: #ebebfa;
$indigo-950: #f7f7ff;
$indigo-900-alpha-008: rgba($indigo-900, 0.08);
+$purple-50: #232150;
+$purple-100: #2f2a6b;
+$purple-200: #453894;
+$purple-300: #5943b6;
+$purple-400: #694cc0;
+$purple-500: #7b58cf;
+$purple-600: #9475db;
+$purple-700: #ac93e6;
+$purple-800: #cbbbf2;
+$purple-900: #e1d8f9;
+$purple-950: #f4f0ff;
+
$gray-lightest: #222;
$gray-light: $gray-50;
$gray-lighter: #303030;
@@ -163,6 +175,18 @@ body.gl-dark {
--indigo-950: #{$indigo-950};
--indigo-900-alpha-008: #{$indigo-900-alpha-008};
+ --purple-50: #{$purple-50};
+ --purple-100: #{$purple-100};
+ --purple-200: #{$purple-200};
+ --purple-300: #{$purple-300};
+ --purple-400: #{$purple-400};
+ --purple-500: #{$purple-500};
+ --purple-600: #{$purple-600};
+ --purple-700: #{$purple-700};
+ --purple-800: #{$purple-800};
+ --purple-900: #{$purple-900};
+ --purple-950: #{$purple-950};
+
--gl-text-color: #{$gray-900};
--border-color: #{$border-color};
@@ -252,6 +276,10 @@ $well-inner-border: $gray-200;
color: $gray-900;
}
+.gl-label-text-dark.gl-label-text-dark {
+ color: $gray-10;
+}
+
// This applies to "gl-labels" from "gitlab-ui"
.gl-label.gl-label-scoped.gl-label-text-dark,
.gl-label.gl-label-scoped.gl-label-text-light {
diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss
index a94169ab494..a9e8b238d78 100644
--- a/app/assets/stylesheets/themes/theme_helper.scss
+++ b/app/assets/stylesheets/themes/theme_helper.scss
@@ -140,6 +140,34 @@
}
}
+ .header-search {
+ background-color: rgba($search-and-nav-links, 0.2) !important;
+
+ &:hover {
+ background-color: rgba($search-and-nav-links, 0.3) !important;
+ }
+
+ svg {
+ color: rgba($search-and-nav-links, 0.8) !important;
+ }
+
+ input {
+ background-color: transparent;
+ color: rgba($search-and-nav-links, 0.8);
+
+ &::placeholder {
+ color: rgba($search-and-nav-links, 0.8);
+ }
+
+ &:focus,
+ &:active {
+ &::placeholder {
+ color: $search-and-nav-links;
+ }
+ }
+ }
+ }
+
.search {
form {
background-color: rgba($search-and-nav-links, 0.2);
@@ -184,7 +212,7 @@
a:hover,
&.active a,
.fly-out-top-item-container {
- background-color: $purple-900;
+ background-color: var(--purple-100, $purple-900);
color: var(--black, $white);
}
}
diff --git a/app/assets/stylesheets/themes/theme_light.scss b/app/assets/stylesheets/themes/theme_light.scss
index b41377475c5..4c3bc1b2298 100644
--- a/app/assets/stylesheets/themes/theme_light.scss
+++ b/app/assets/stylesheets/themes/theme_light.scss
@@ -45,6 +45,16 @@ body {
}
}
+ .header-search {
+ background-color: $white !important;
+ box-shadow: inset 0 0 0 1px $border-color !important;
+
+ &:hover {
+ background-color: $white !important;
+ box-shadow: inset 0 0 0 1px $blue-200 !important;
+ }
+ }
+
.search {
form {
background-color: $white;
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index ccad503c1ed..ec70926b418 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -245,11 +245,16 @@ $gl-line-height-42: px-to-rem(42px);
width: $grid-size * 28;
}
-// Will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1491
+// Will be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2347 is merged
.gl-min-w-8 {
min-width: $gl-spacing-scale-8;
}
+// Will be removed after https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2347 is merged
+.gl-min-w-10 {
+ min-width: $gl-spacing-scale-10;
+}
+
// Will both be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1526
.gl-opacity-6 {
opacity: 0.6;
@@ -258,3 +263,21 @@ $gl-line-height-42: px-to-rem(42px);
.gl-opacity-7 {
opacity: 0.7;
}
+
+/**
+ Note: ::-webkit-scrollbar is a non-standard rule only
+ supported by webkit browsers.
+
+ It is added here to migrate components that use
+ scrolling-links() mixin from `app/assets/stylesheets/framework/mixins.scss`.
+
+ It should not be used elsewhere: it may impact accessibility as well as
+ add browser compatibility issues.
+
+ See: https://developer.mozilla.org/en-US/docs/Web/CSS/::-webkit-scrollbar
+**/
+.gl-webkit-scrollbar-display-none {
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 449aa90b0e6..ce7d64336c8 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -18,7 +18,10 @@ class Admin::ApplicationsController < Admin::ApplicationController
end
def new
- @application = Doorkeeper::Application.new
+ # Default access tokens to expire. This preserves backward compatibility
+ # with existing applications. This will be removed in 15.0.
+ # Removal issue: https://gitlab.com/gitlab-org/gitlab/-/issues/340848
+ @application = Doorkeeper::Application.new(expire_access_tokens: true)
end
def edit
@@ -55,10 +58,13 @@ class Admin::ApplicationsController < Admin::ApplicationController
@application = ApplicationsFinder.new(id: params[:id]).execute
end
- # Only allow a trusted parameter "white list" through.
+ def permitted_params
+ super << :trusted
+ end
+
def application_params
- params
- .require(:doorkeeper_application)
- .permit(:name, :redirect_uri, :trusted, :scopes, :confidential)
+ super.tap do |params|
+ params[:owner] = nil
+ end
end
end
diff --git a/app/controllers/admin/background_migrations_controller.rb b/app/controllers/admin/background_migrations_controller.rb
index 65b47308e4c..e21e6fd2dcb 100644
--- a/app/controllers/admin/background_migrations_controller.rb
+++ b/app/controllers/admin/background_migrations_controller.rb
@@ -29,9 +29,16 @@ class Admin::BackgroundMigrationsController < Admin::ApplicationController
redirect_back fallback_location: { action: 'index' }
end
+ def retry
+ migration = batched_migration_class.find(params[:id])
+ migration.retry_failed_jobs! if migration.failed?
+
+ redirect_back fallback_location: { action: 'index' }
+ end
+
private
def batched_migration_class
- Gitlab::Database::BackgroundMigration::BatchedMigration
+ @batched_migration_class ||= Gitlab::Database::BackgroundMigration::BatchedMigration
end
end
diff --git a/app/controllers/admin/runner_projects_controller.rb b/app/controllers/admin/runner_projects_controller.rb
index 3b408de5f01..fdf681de9ef 100644
--- a/app/controllers/admin/runner_projects_controller.rb
+++ b/app/controllers/admin/runner_projects_controller.rb
@@ -9,7 +9,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
@runner = Ci::Runner.find(params[:runner_project][:runner_id])
if @runner.assign_to(@project, current_user)
- redirect_to admin_runner_path(@runner)
+ redirect_to admin_runner_path(@runner), notice: s_('Runners|Runner assigned to project.')
else
redirect_to admin_runner_path(@runner), alert: 'Failed adding runner to project'
end
@@ -20,7 +20,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
runner = rp.runner
rp.destroy
- redirect_to admin_runner_path(runner), status: :found
+ redirect_to admin_runner_path(runner), status: :found, notice: s_('Runners|Runner unassigned from project.')
end
private
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 3801906635f..9c556d16913 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -307,7 +307,7 @@ class Admin::UsersController < Admin::ApplicationController
end
def user
- @user ||= find_routable!(User, params[:id], request.path_info)
+ @user ||= find_routable!(User, params[:id], request.fullpath)
end
def build_canonical_path(user)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 34bad74a9fc..a83458f3260 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -42,6 +42,7 @@ class ApplicationController < ActionController::Base
# Make sure the `auth_user` is memoized so it can be logged, we do this after
# all other before filters that could have set the user.
before_action :auth_user
+ before_action :limit_session_time, if: -> { !current_user }
prepend_around_action :set_current_context
@@ -51,7 +52,7 @@ class ApplicationController < ActionController::Base
around_action :set_current_admin
after_action :set_page_title_header, if: :json_request?
- after_action :limit_session_time, if: -> { !current_user }
+ after_action :ensure_authenticated_session_time, if: -> { current_user }
protect_from_forgery with: :exception, prepend: true
@@ -62,7 +63,8 @@ class ApplicationController < ActionController::Base
:bitbucket_import_enabled?, :bitbucket_import_configured?,
:bitbucket_server_import_enabled?, :fogbugz_import_enabled?,
:git_import_enabled?, :gitlab_project_import_enabled?,
- :manifest_import_enabled?, :phabricator_import_enabled?
+ :manifest_import_enabled?, :phabricator_import_enabled?,
+ :masked_page_url
# Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security
# concerns due to caching private data.
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index f0f074792ed..7dea6191fa4 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -27,9 +27,7 @@ module Boards
list_service = Boards::Issues::ListService.new(board_parent, current_user, filter_params)
issues = issues_from(list_service)
- if Gitlab::Database.read_write? && !board.disabled_for?(current_user)
- Issue.move_nulls_to_end(issues)
- end
+ ::Boards::Issues::ListService.initialize_relative_positions(board, current_user, issues)
render_issues(issues, list_service.metadata)
end
diff --git a/app/controllers/concerns/integrations/params.rb b/app/controllers/concerns/integrations/params.rb
index 10122b4c77b..62585ab95af 100644
--- a/app/controllers/concerns/integrations/params.rb
+++ b/app/controllers/concerns/integrations/params.rb
@@ -77,18 +77,15 @@ module Integrations
:webhook
].freeze
- # Parameters to ignore if no value is specified
- FILTER_BLANK_PARAMS = [:password].freeze
-
def integration_params
- dynamic_params = @integration.event_channel_names + @integration.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ dynamic_params = integration.event_channel_names + integration.event_names
allowed = allowed_integration_params + dynamic_params
return_value = params.permit(:id, integration: allowed, service: allowed)
return_value[:integration] ||= return_value.delete(:service)
param_values = return_value[:integration]
if param_values.is_a?(ActionController::Parameters)
- FILTER_BLANK_PARAMS.each do |param|
+ integration.password_fields.each do |param|
param_values.delete(param) if param_values[param].blank?
end
end
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index d2d2e656af8..4841225de08 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -13,8 +13,16 @@ module IssuableCollections
private
+ def show_alert_if_search_is_disabled
+ return if current_user || params[:search].blank? || !html_request? || Feature.disabled?(:disable_anonymous_search, type: :ops)
+
+ flash.now[:notice] = _('You must sign in to search for specific terms.')
+ end
+
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def set_issuables_index
+ show_alert_if_search_is_disabled
+
@issuables = issuables_collection
unless pagination_disabled?
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index ca2979a5a29..b68db0e3f9f 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -7,6 +7,8 @@ module IssuableCollectionsAction
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def issues
+ show_alert_if_search_is_disabled
+
@issues = issuables_collection
.non_archived
.page(params[:page])
@@ -20,6 +22,8 @@ module IssuableCollectionsAction
end
def merge_requests
+ show_alert_if_search_is_disabled
+
@merge_requests = issuables_collection.page(params[:page])
@issuable_meta_data = Gitlab::IssuableMetadata.new(current_user, @merge_requests).data
diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb
index d97e22df472..d2c746db12d 100644
--- a/app/controllers/concerns/oauth_applications.rb
+++ b/app/controllers/concerns/oauth_applications.rb
@@ -18,4 +18,14 @@ module OauthApplications
def load_scopes
@scopes ||= Doorkeeper.configuration.scopes
end
+
+ def permitted_params
+ %i{name redirect_uri scopes confidential expire_access_tokens}
+ end
+
+ def application_params
+ params
+ .require(:doorkeeper_application)
+ .permit(*permitted_params)
+ end
end
diff --git a/app/controllers/concerns/project_unauthorized.rb b/app/controllers/concerns/project_unauthorized.rb
index b58f6589f9b..563d6b6273b 100644
--- a/app/controllers/concerns/project_unauthorized.rb
+++ b/app/controllers/concerns/project_unauthorized.rb
@@ -3,7 +3,7 @@
module ProjectUnauthorized
module ControllerActions
def self.on_routable_not_found
- lambda do |routable, path_info|
+ lambda do |routable, full_path|
return unless routable.is_a?(Project)
label = routable.external_authorization_classification_label
diff --git a/app/controllers/concerns/renders_projects_list.rb b/app/controllers/concerns/renders_projects_list.rb
index be45c676ad6..05bd9972ee7 100644
--- a/app/controllers/concerns/renders_projects_list.rb
+++ b/app/controllers/concerns/renders_projects_list.rb
@@ -4,9 +4,10 @@ module RendersProjectsList
def prepare_projects_for_rendering(projects)
preload_max_member_access_for_collection(Project, projects)
- # Call the forks count method on every project, so the BatchLoader would load them all at
+ # Call the count methods on every project, so the BatchLoader would load them all at
# once when the entities are rendered
projects.each(&:forks_count)
+ projects.each(&:open_issues_count)
projects
end
diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb
index 57108369c64..e34d6b09c24 100644
--- a/app/controllers/concerns/routable_actions.rb
+++ b/app/controllers/concerns/routable_actions.rb
@@ -3,13 +3,13 @@
module RoutableActions
extend ActiveSupport::Concern
- def find_routable!(routable_klass, routable_full_path, path_info, extra_authorization_proc: nil)
+ def find_routable!(routable_klass, routable_full_path, full_path, extra_authorization_proc: nil)
routable = routable_klass.find_by_full_path(routable_full_path, follow_redirects: request.get?)
if routable_authorized?(routable, extra_authorization_proc)
ensure_canonical_path(routable, routable_full_path)
routable
else
- perform_not_found_actions(routable, not_found_actions, path_info)
+ perform_not_found_actions(routable, not_found_actions, full_path)
route_not_found unless performed?
@@ -21,11 +21,11 @@ module RoutableActions
[ProjectUnauthorized::ControllerActions.on_routable_not_found]
end
- def perform_not_found_actions(routable, actions, path_info)
+ def perform_not_found_actions(routable, actions, full_path)
actions.each do |action|
break if performed?
- instance_exec(routable, path_info, &action)
+ instance_exec(routable, full_path, &action)
end
end
diff --git a/app/controllers/concerns/sessionless_authentication.rb b/app/controllers/concerns/sessionless_authentication.rb
index 3c8a683439a..58e65ba20e2 100644
--- a/app/controllers/concerns/sessionless_authentication.rb
+++ b/app/controllers/concerns/sessionless_authentication.rb
@@ -8,7 +8,6 @@ module SessionlessAuthentication
# This filter handles personal access tokens, atom requests with rss tokens, and static object tokens
def authenticate_sessionless_user!(request_format)
user = request_authenticator.find_sessionless_user(request_format)
-
sessionless_sign_in(user) if user
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index d861ef646f8..74ad78ff4c1 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -36,7 +36,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def starred
@projects = load_projects(params.merge(starred: true))
- .includes(:forked_from_project, :topics)
+ .includes(:forked_from_project, :topics, :topics_acts_as_taggable)
@groups = []
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index e0973b0f3b4..3dc6a16cbc1 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -26,6 +26,7 @@ class Explore::ProjectsController < Explore::ApplicationController
feature_category :projects
def index
+ show_alert_if_search_is_disabled
@projects = load_projects
respond_to do |format|
@@ -120,6 +121,12 @@ class Explore::ProjectsController < Explore::ApplicationController
end
end
end
+
+ def show_alert_if_search_is_disabled
+ return if current_user || params[:name].blank? && params[:search].blank? || !html_request? || Feature.disabled?(:disable_anonymous_project_search, type: :ops)
+
+ flash.now[:notice] = _('You must sign in to search for specific projects.')
+ end
end
Explore::ProjectsController.prepend_mod_with('Explore::ProjectsController')
diff --git a/app/controllers/groups/application_controller.rb b/app/controllers/groups/application_controller.rb
index aa0d49902c3..ab67a007bd9 100644
--- a/app/controllers/groups/application_controller.rb
+++ b/app/controllers/groups/application_controller.rb
@@ -16,7 +16,7 @@ class Groups::ApplicationController < ApplicationController
private
def group
- @group ||= find_routable!(Group, params[:group_id] || params[:id], request.path_info)
+ @group ||= find_routable!(Group, params[:group_id] || params[:id], request.fullpath)
end
def group_projects
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index 96a3b38669d..60708c13b85 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -7,7 +7,6 @@ class Groups::BoardsController < Groups::ApplicationController
before_action :assign_endpoint_vars
before_action do
- push_frontend_feature_flag(:graphql_board_lists, group, default_enabled: :yaml)
push_frontend_feature_flag(:issue_boards_filtered_search, group, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, group, default_enabled: :yaml)
push_frontend_feature_flag(:swimlanes_buffered_rendering, group, default_enabled: :yaml)
diff --git a/app/controllers/groups/clusters/integrations_controller.rb b/app/controllers/groups/clusters/integrations_controller.rb
index 61b308f7d1b..4ab8c021ee2 100644
--- a/app/controllers/groups/clusters/integrations_controller.rb
+++ b/app/controllers/groups/clusters/integrations_controller.rb
@@ -13,6 +13,6 @@ class Groups::Clusters::IntegrationsController < Clusters::IntegrationsControlle
end
def group
- @group ||= find_routable!(Group, params[:group_id] || params[:id], request.path_info)
+ @group ||= find_routable!(Group, params[:group_id] || params[:id], request.fullpath)
end
end
diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb
index 6f3eecf8296..666a96d6fc0 100644
--- a/app/controllers/groups/clusters_controller.rb
+++ b/app/controllers/groups/clusters_controller.rb
@@ -15,7 +15,7 @@ class Groups::ClustersController < Clusters::ClustersController
end
def group
- @group ||= find_routable!(Group, params[:group_id] || params[:id], request.path_info)
+ @group ||= find_routable!(Group, params[:group_id] || params[:id], request.fullpath)
end
def metrics_dashboard_params
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index dbbfdd76fe8..f37c08da22a 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -10,6 +10,8 @@ class Groups::RunnersController < Groups::ApplicationController
feature_category :runner
def index
+ finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group })
+ @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000)
end
def runner_list_group_view_vue_ui_enabled
@@ -59,7 +61,7 @@ class Groups::RunnersController < Groups::ApplicationController
private
def runner
- @runner ||= Ci::RunnersFinder.new(current_user: current_user, group: @group, params: {}).execute
+ @runner ||= Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }).execute
.except(:limit, :offset)
.find(params[:id])
end
diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb
index cefb5425867..f05a96d7810 100644
--- a/app/controllers/groups/settings/applications_controller.rb
+++ b/app/controllers/groups/settings/applications_controller.rb
@@ -54,8 +54,10 @@ module Groups
# https://gitlab.com/gitlab-org/gitlab/-/issues/324187
@applications = @group.oauth_applications.limit(100)
- # Don't overwrite a value possibly set by `create`
- @application ||= Doorkeeper::Application.new
+ # Default access tokens to expire. This preserves backward compatibility
+ # with existing applications. This will be removed in 15.0.
+ # Removal issue: https://gitlab.com/gitlab-org/gitlab/-/issues/340848
+ @application ||= Doorkeeper::Application.new(expire_access_tokens: true)
end
def set_application
@@ -63,12 +65,9 @@ module Groups
end
def application_params
- params
- .require(:doorkeeper_application)
- .permit(:name, :redirect_uri, :scopes, :confidential)
- .tap do |params|
- params[:owner] = @group
- end
+ super.tap do |params|
+ params[:owner] = @group
+ end
end
end
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 0f40c9bfd2c..a290ef9b5e7 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -17,7 +17,7 @@ module Groups
NUMBER_OF_RUNNERS_PER_PAGE = 4
def show
- runners_finder = Ci::RunnersFinder.new(current_user: current_user, group: @group, params: params)
+ runners_finder = Ci::RunnersFinder.new(current_user: current_user, params: params.merge({ group: @group }))
# We need all runners for count
@all_group_runners = runners_finder.execute.except(:limit, :offset)
@group_runners = runners_finder.execute.page(params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 2796760fbe1..a419171039e 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -33,6 +33,7 @@ class GroupsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuables_list, @group)
+ push_frontend_feature_flag(:vue_issues_list, @group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, @group, default_enabled: :yaml)
end
@@ -64,6 +65,7 @@ class GroupsController < Groups::ApplicationController
def new
@group = Group.new(params.permit(:parent_id))
+ @group.build_namespace_settings
end
def create
@@ -269,7 +271,9 @@ class GroupsController < Groups::ApplicationController
:default_branch_name,
:allow_mfa_for_subgroups,
:resource_access_token_creation_allowed,
- :prevent_sharing_groups_outside_hierarchy
+ :prevent_sharing_groups_outside_hierarchy,
+ :setup_for_company,
+ :jobs_to_be_done
]
end
@@ -342,7 +346,15 @@ class GroupsController < Groups::ApplicationController
render action: 'new'
end
- def successful_creation_hooks; end
+ def successful_creation_hooks
+ update_user_role_and_setup_for_company
+ end
+
+ def update_user_role_and_setup_for_company
+ user_params = params.fetch(:user, {}).permit(:role)
+ user_params[:setup_for_company] = @group.setup_for_company if !@group.setup_for_company.nil? && current_user.setup_for_company.nil?
+ Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute if user_params.present?
+ end
def groups
if @group.supports_events?
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 57bd39bbe06..d32755dbd94 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -62,14 +62,10 @@ class Import::BitbucketController < Import::BaseController
protected
- # rubocop: disable CodeReuse/ActiveRecord
override :importable_repos
def importable_repos
- already_added_projects_names = already_added_projects.map(&:import_source)
-
- bitbucket_repos.reject { |repo| already_added_projects_names.include?(repo.full_name) || !repo.valid? }
+ bitbucket_repos.filter { |repo| repo.valid? }
end
- # rubocop: enable CodeReuse/ActiveRecord
override :incompatible_repos
def incompatible_repos
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index 1846b1e0cec..31e9694ca1d 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -62,16 +62,10 @@ class Import::BitbucketServerController < Import::BaseController
protected
- # rubocop: disable CodeReuse/ActiveRecord
override :importable_repos
def importable_repos
- # Use the import URL to filter beyond what BaseService#find_already_added_projects
- already_added_projects = filter_added_projects('bitbucket_server', bitbucket_repos.map(&:browse_url))
- already_added_projects_names = already_added_projects.map(&:import_source)
-
- bitbucket_repos.reject { |repo| already_added_projects_names.include?(repo.browse_url) || !repo.valid? }
+ bitbucket_repos.filter { |repo| repo.valid? }
end
- # rubocop: enable CodeReuse/ActiveRecord
override :incompatible_repos
def incompatible_repos
@@ -90,12 +84,6 @@ class Import::BitbucketServerController < Import::BaseController
private
- # rubocop: disable CodeReuse/ActiveRecord
- def filter_added_projects(import_type, import_sources)
- current_user.created_projects.where(import_type: import_type, import_source: import_sources).with_import_state
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def client
@client ||= BitbucketServer::Client.new(credentials)
end
diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb
index e99b8cfa0c7..da936215ad4 100644
--- a/app/controllers/import/bulk_imports_controller.rb
+++ b/app/controllers/import/bulk_imports_controller.rb
@@ -112,7 +112,7 @@ class Import::BulkImportsController < ApplicationController
end
def ensure_group_import_enabled
- render_404 unless Feature.enabled?(:bulk_import)
+ render_404 unless Feature.enabled?(:bulk_import, default_enabled: :yaml)
end
def access_token_key
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 9f91f3a1e1c..377292d47d8 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -74,16 +74,10 @@ class Import::FogbugzController < Import::BaseController
protected
- # rubocop: disable CodeReuse/ActiveRecord
override :importable_repos
def importable_repos
- repos = client.repos
-
- already_added_projects_names = already_added_projects.map(&:import_source)
-
- repos.reject { |repo| already_added_projects_names.include? repo.name }
+ client.repos
end
- # rubocop: enable CodeReuse/ActiveRecord
override :incompatible_repos
def incompatible_repos
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 22bcd14d664..d7aebd25432 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -64,9 +64,7 @@ class Import::GithubController < Import::BaseController
# rubocop: disable CodeReuse/ActiveRecord
override :importable_repos
def importable_repos
- already_added_projects_names = already_added_projects.pluck(:import_source)
-
- client_repos.reject { |repo| already_added_projects_names.include?(repo.full_name) }
+ client_repos.to_a
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index cc68eb02741..662b02010ba 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -39,16 +39,10 @@ class Import::GitlabController < Import::BaseController
protected
- # rubocop: disable CodeReuse/ActiveRecord
override :importable_repos
def importable_repos
- repos = client.projects(starting_page: 1, page_limit: MAX_PROJECT_PAGES, per_page: PER_PAGE_PROJECTS)
-
- already_added_projects_names = already_added_projects.map(&:import_source)
-
- repos.reject { |repo| already_added_projects_names.include? repo["path_with_namespace"] }
+ client.projects(starting_page: 1, page_limit: MAX_PROJECT_PAGES, per_page: PER_PAGE_PROJECTS)
end
- # rubocop: enable CodeReuse/ActiveRecord
override :incompatible_repos
def incompatible_repos
diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb
index 8497e15c07c..956d0c9a2ae 100644
--- a/app/controllers/import/manifest_controller.rb
+++ b/app/controllers/import/manifest_controller.rb
@@ -41,7 +41,7 @@ class Import::ManifestController < Import::BaseController
end
def create
- repository = repositories.find do |project|
+ repository = importable_repos.find do |project|
project[:id] == params[:repo_id].to_i
end
@@ -56,14 +56,10 @@ class Import::ManifestController < Import::BaseController
protected
- # rubocop: disable CodeReuse/ActiveRecord
override :importable_repos
def importable_repos
- already_added_projects_names = already_added_projects.pluck(:import_url)
-
- repositories.reject { |repo| already_added_projects_names.include?(repo[:url]) }
+ @importable_repos ||= manifest_import_metadata.repositories
end
- # rubocop: enable CodeReuse/ActiveRecord
override :incompatible_repos
def incompatible_repos
@@ -88,7 +84,7 @@ class Import::ManifestController < Import::BaseController
private
def ensure_import_vars
- unless group && repositories.present?
+ unless group && importable_repos.present?
redirect_to(new_import_manifest_path)
end
end
@@ -103,10 +99,6 @@ class Import::ManifestController < Import::BaseController
@manifest_import_status ||= Gitlab::ManifestImport::Metadata.new(current_user, fallback: session)
end
- def repositories
- @repositories ||= manifest_import_metadata.repositories
- end
-
def find_jobs
find_already_added_projects.to_json(only: [:id], methods: [:import_status])
end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 7f5750d2011..4242f918ea0 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -77,7 +77,12 @@ class InvitesController < ApplicationController
def track_invite_join_click
return unless member && initial_invite_email?
- experiment(:invite_email_preview_text, actor: member).track(:join_clicked) if params[:experiment_name] == 'invite_email_preview_text'
+ if params[:experiment_name] == 'invite_email_preview_text'
+ experiment(:invite_email_preview_text, actor: member).track(:join_clicked)
+ elsif params[:experiment_name] == 'invite_email_from'
+ experiment(:invite_email_from, actor: member).track(:join_clicked)
+ end
+
Gitlab::Tracking.event(self.class.name, 'join_clicked', label: 'invite_email', property: member.id.to_s)
end
diff --git a/app/controllers/jira_connect/installations_controller.rb b/app/controllers/jira_connect/installations_controller.rb
new file mode 100644
index 00000000000..401bc4f9c87
--- /dev/null
+++ b/app/controllers/jira_connect/installations_controller.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class JiraConnect::InstallationsController < JiraConnect::ApplicationController
+ def index
+ render json: installation_json(current_jira_installation)
+ end
+
+ def update
+ if current_jira_installation.update(installation_params)
+ render json: installation_json(current_jira_installation)
+ else
+ render(
+ json: { errors: current_jira_installation.errors },
+ status: :unprocessable_entity
+ )
+ end
+ end
+
+ private
+
+ def installation_json(installation)
+ {
+ gitlab_com: installation.instance_url.blank?,
+ instance_url: installation.instance_url
+ }
+ end
+
+ def installation_params
+ params.require(:installation).permit(:instance_url)
+ end
+end
diff --git a/app/controllers/members/mailgun/permanent_failures_controller.rb b/app/controllers/members/mailgun/permanent_failures_controller.rb
new file mode 100644
index 00000000000..685faa34694
--- /dev/null
+++ b/app/controllers/members/mailgun/permanent_failures_controller.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Members
+ module Mailgun
+ class PermanentFailuresController < ApplicationController
+ respond_to :json
+
+ skip_before_action :authenticate_user!
+ skip_before_action :verify_authenticity_token
+
+ before_action :ensure_feature_enabled!
+ before_action :authenticate_signature!
+ before_action :validate_invite_email!
+
+ feature_category :authentication_and_authorization
+
+ def create
+ webhook_processor.execute
+
+ head :ok
+ end
+
+ private
+
+ def ensure_feature_enabled!
+ render_406 unless Gitlab::CurrentSettings.mailgun_events_enabled?
+ end
+
+ def authenticate_signature!
+ access_denied! unless valid_signature?
+ end
+
+ def valid_signature?
+ return false if Gitlab::CurrentSettings.mailgun_signing_key.blank?
+
+ # per this guide: https://documentation.mailgun.com/en/latest/user_manual.html#webhooks
+ digest = OpenSSL::Digest.new('SHA256')
+ data = [params.dig(:signature, :timestamp), params.dig(:signature, :token)].join
+
+ hmac_digest = OpenSSL::HMAC.hexdigest(digest, Gitlab::CurrentSettings.mailgun_signing_key, data)
+
+ ActiveSupport::SecurityUtils.secure_compare(params.dig(:signature, :signature), hmac_digest)
+ end
+
+ def validate_invite_email!
+ # permanent_failures webhook does not provide a way to filter failures, so we'll get them all on this endpoint
+ # and we only care about our invite_emails
+ render_406 unless payload[:tags]&.include?(::Members::Mailgun::INVITE_EMAIL_TAG)
+ end
+
+ def webhook_processor
+ ::Members::Mailgun::ProcessWebhookService.new(payload)
+ end
+
+ def payload
+ @payload ||= params.permit!['event-data']
+ end
+
+ def render_406
+ # failure to stop retries per https://documentation.mailgun.com/en/latest/user_manual.html#webhooks
+ head :not_acceptable
+ end
+ end
+ end
+end
diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb
index 8158db282fb..81f188256ba 100644
--- a/app/controllers/oauth/applications_controller.rb
+++ b/app/controllers/oauth/applications_controller.rb
@@ -25,7 +25,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
end
def create
- @application = Applications::CreateService.new(current_user, create_application_params).execute(request)
+ @application = Applications::CreateService.new(current_user, application_params).execute(request)
if @application.persisted?
flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create])
@@ -51,8 +51,10 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
@authorized_anonymous_tokens = @authorized_tokens.reject(&:application)
@authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?)
- # Don't overwrite a value possibly set by `create`
- @application ||= Doorkeeper::Application.new
+ # Default access tokens to expire. This preserves backward compatibility
+ # with existing applications. This will be removed in 15.0.
+ # Removal issue: https://gitlab.com/gitlab-org/gitlab/-/issues/340848
+ @application ||= Doorkeeper::Application.new(expire_access_tokens: true)
end
# Override Doorkeeper to scope to the current user
@@ -64,8 +66,8 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
render "errors/not_found", layout: "errors", status: :not_found
end
- def create_application_params
- application_params.tap do |params|
+ def application_params
+ super.tap do |params|
params[:owner] = current_user
end
end
diff --git a/app/controllers/profiles/groups_controller.rb b/app/controllers/profiles/groups_controller.rb
index 2571e92e071..5962b10c44b 100644
--- a/app/controllers/profiles/groups_controller.rb
+++ b/app/controllers/profiles/groups_controller.rb
@@ -6,7 +6,7 @@ class Profiles::GroupsController < Profiles::ApplicationController
feature_category :users
def update
- group = find_routable!(Group, params[:id], request.path_info)
+ group = find_routable!(Group, params[:id], request.fullpath)
notification_setting = current_user.notification_settings_for(group)
if notification_setting.update(update_params)
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index effd3514c1b..5eb46421583 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -2,6 +2,7 @@
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
skip_before_action :check_two_factor_requirement
+ before_action :ensure_verified_primary_email, only: [:show, :create]
before_action do
push_frontend_feature_flag(:webauthn)
end
@@ -57,7 +58,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
@codes = user.generate_otp_backup_codes!
end
- helpers.dismiss_account_recovery_regular_check
+ helpers.dismiss_two_factor_auth_recovery_settings_check
render 'create'
else
@@ -108,7 +109,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
Users::UpdateService.new(current_user, user: current_user).execute! do |user|
@codes = user.generate_otp_backup_codes!
- helpers.dismiss_account_recovery_regular_check
+ helpers.dismiss_two_factor_auth_recovery_settings_check
end
end
@@ -218,4 +219,12 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.})
.html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe }
end
+
+ def ensure_verified_primary_email
+ return unless Feature.enabled?(:ensure_verified_primary_email_for_2fa, default_enabled: :yaml)
+
+ unless current_user.two_factor_enabled? || current_user.primary_email_verified?
+ redirect_to profile_emails_path, notice: s_('You need to verify your primary email first before enabling Two-Factor Authentication.')
+ end
+ end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 6cc602fec88..29ae268ef67 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -103,8 +103,8 @@ class ProfilesController < Profiles::ApplicationController
@username_param ||= user_params.require(:username)
end
- def user_params
- @user_params ||= params.require(:user).permit(
+ def user_params_attributes
+ [
:avatar,
:bio,
:email,
@@ -130,6 +130,12 @@ class ProfilesController < Profiles::ApplicationController
:pronouns,
:pronunciation,
status: [:emoji, :message, :availability]
- )
+ ]
+ end
+
+ def user_params
+ @user_params ||= params.require(:user).permit(user_params_attributes)
end
end
+
+ProfilesController.prepend_mod
diff --git a/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb b/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb
index c51a5ac7b88..bf8742bf6e8 100644
--- a/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb
+++ b/app/controllers/projects/analytics/cycle_analytics/summary_controller.rb
@@ -20,7 +20,7 @@ class Projects::Analytics::CycleAnalytics::SummaryController < Projects::Applica
end
def allowed_params
- params.permit(:created_after, :created_before)
+ request_params.to_data_collector_params
end
end
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index cf2ecb0673e..7a03e7b84b7 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -26,7 +26,7 @@ class Projects::ApplicationController < ApplicationController
path = File.join(params[:namespace_id], params[:project_id] || params[:id])
auth_proc = ->(project) { !project.pending_delete? }
- @project = find_routable!(Project, path, request.path_info, extra_authorization_proc: auth_proc)
+ @project = find_routable!(Project, path, request.fullpath, extra_authorization_proc: auth_proc)
end
def build_canonical_path(project)
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 035b76abfd6..316582f3994 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -8,7 +8,6 @@ class Projects::BoardsController < Projects::ApplicationController
before_action :assign_endpoint_vars
before_action do
push_frontend_feature_flag(:swimlanes_buffered_rendering, project, default_enabled: :yaml)
- push_frontend_feature_flag(:graphql_board_lists, project, default_enabled: :yaml)
push_frontend_feature_flag(:issue_boards_filtered_search, project, default_enabled: :yaml)
push_frontend_feature_flag(:board_multi_select, project, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index ed1d5ca9594..550877548e1 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -4,7 +4,6 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
push_frontend_feature_flag(:pipeline_editor_empty_state_action, @project, default_enabled: :yaml)
- push_frontend_feature_flag(:pipeline_editor_branch_switcher, @project, default_enabled: :yaml)
push_frontend_feature_flag(:pipeline_editor_drawer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:schema_linting, @project, default_enabled: :yaml)
end
diff --git a/app/controllers/projects/clusters/integrations_controller.rb b/app/controllers/projects/clusters/integrations_controller.rb
index eed6c1dccc4..77314d19469 100644
--- a/app/controllers/projects/clusters/integrations_controller.rb
+++ b/app/controllers/projects/clusters/integrations_controller.rb
@@ -10,6 +10,6 @@ class Projects::Clusters::IntegrationsController < ::Clusters::IntegrationsContr
end
def project
- @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), request.path_info)
+ @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), request.fullpath)
end
end
diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb
index 0aef497d28d..8f45fa1cb9f 100644
--- a/app/controllers/projects/clusters_controller.rb
+++ b/app/controllers/projects/clusters_controller.rb
@@ -17,7 +17,7 @@ class Projects::ClustersController < Clusters::ClustersController
end
def project
- @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), request.path_info)
+ @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), request.fullpath)
end
def repository
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index cac0aa9d513..23dabd885c8 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -213,8 +213,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ def allowed_environment_attributes
+ attributes = [:external_url]
+ attributes << :name if action_name == "create"
+ attributes
+ end
+
def environment_params
- params.require(:environment).permit(:name, :external_url)
+ params.require(:environment).permit(allowed_environment_attributes)
end
def environment
diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb
index b99c233411a..7c0da8f8a24 100644
--- a/app/controllers/projects/feature_flags_controller.rb
+++ b/app/controllers/projects/feature_flags_controller.rb
@@ -10,9 +10,6 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
before_action :feature_flag, only: [:edit, :update, :destroy]
- before_action :ensure_flag_writable!, only: [:update]
- before_action :exclude_legacy_flags_check, only: [:edit]
-
feature_category :feature_flags
def index
@@ -98,18 +95,6 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
@feature_flag ||= @noteable = project.operations_feature_flags.find_by_iid!(params[:iid])
end
- def ensure_flag_writable!
- if feature_flag.legacy_flag?
- render_error_json(['Legacy feature flags are read-only'])
- end
- end
-
- def exclude_legacy_flags_check
- if feature_flag.legacy_flag?
- not_found
- end
- end
-
def create_params
params.require(:operations_feature_flag)
.permit(:name, :description, :active, :version,
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index bdfaaf2b143..f885ff9b45b 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -42,9 +42,8 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
- push_frontend_feature_flag(:usage_data_design_action, project, default_enabled: true)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
- push_frontend_feature_flag(:vue_issues_list, project)
+ push_frontend_feature_flag(:vue_issues_list, project&.group, default_enabled: :yaml)
push_frontend_feature_flag(:iteration_cadences, project&.group, default_enabled: :yaml)
end
@@ -118,7 +117,11 @@ class Projects::IssuesController < Projects::ApplicationController
@issue = @noteable = service.execute
@merge_request_to_resolve_discussions_of = service.merge_request_to_resolve_discussions_of
- @discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve]
+
+ if params[:discussion_to_resolve]
+ Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_resolve_thread_in_issue_action(user: current_user)
+ @discussion_to_resolve = service.discussions_to_resolve.first
+ end
respond_with(@issue)
end
@@ -228,7 +231,7 @@ class Projects::IssuesController < Projects::ApplicationController
IssuableExportCsvWorker.perform_async(:issue, current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
index_path = project_issues_path(project)
- message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email }
+ message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email_or_default }
redirect_to(index_path, notice: message)
end
diff --git a/app/controllers/projects/learn_gitlab_controller.rb b/app/controllers/projects/learn_gitlab_controller.rb
index 162ba9bd5cb..91a43c5f03f 100644
--- a/app/controllers/projects/learn_gitlab_controller.rb
+++ b/app/controllers/projects/learn_gitlab_controller.rb
@@ -7,13 +7,11 @@ class Projects::LearnGitlabController < Projects::ApplicationController
feature_category :users
def index
- push_frontend_experiment(:learn_gitlab_a, subject: current_user)
- push_frontend_experiment(:learn_gitlab_b, subject: current_user)
end
private
def check_experiment_enabled?
- return access_denied! unless helpers.learn_gitlab_experiment_enabled?(project)
+ return access_denied! unless helpers.learn_gitlab_enabled?(project)
end
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 8ccc658dfe7..1188aec24a8 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -42,7 +42,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
allow_tree_conflicts: display_merge_conflicts_in_diff?
}
- if diff_options_hash[:paths].blank? && Feature.enabled?(:diffs_batch_render_cached, project, default_enabled: :yaml)
+ if diff_options_hash[:paths].blank?
# NOTE: Any variables that would affect the resulting json needs to be added to the cache_context to avoid stale cache issues.
cache_context = [
current_user&.cache_key,
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 8b3f2df69df..cb68aaf4583 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -13,6 +13,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include DiffHelper
include Gitlab::Cache::Helpers
+ prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
skip_before_action :merge_request, only: [:index, :bulk_update, :export_csv]
before_action :apply_diff_view_cookie!, only: [:show]
before_action :disable_query_limiting, only: [:assign_related_issues, :update]
@@ -34,12 +35,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:merge_request_widget_graphql, @project, default_enabled: :yaml)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project, default_enabled: :yaml)
push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
- push_frontend_feature_flag(:local_file_reviews, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:usage_data_i_testing_summary_widget_total, @project, default_enabled: :yaml)
push_frontend_feature_flag(:improved_emoji_picker, project, default_enabled: :yaml)
push_frontend_feature_flag(:diffs_virtual_scrolling, project, default_enabled: :yaml)
+ push_frontend_feature_flag(:restructured_mr_widget, project, default_enabled: :yaml)
# Usage data feature flags
push_frontend_feature_flag(:users_expanding_widgets_usage_data, @project, default_enabled: :yaml)
@@ -85,6 +86,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
respond_to do |format|
format.html
+ format.atom { render layout: 'xml.atom' }
format.json do
render json: {
html: view_to_html_string("projects/merge_requests/_merge_requests")
@@ -124,13 +126,17 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
set_pipeline_variables
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do
+ @number_of_pipelines = @pipelines.size
+ end
+
render
end
format.json do
Gitlab::PollingInterval.set_header(response, interval: 10_000)
- if params[:serializer] == 'sidebar_extras' && Feature.enabled?(:merge_request_show_render_cached, @project, default_enabled: :yaml)
+ if params[:serializer] == 'sidebar_extras'
cache_context = [
params[:serializer],
current_user&.cache_key,
@@ -173,7 +179,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
# or from cache if already merged
@commits =
set_commits_for_rendering(
- @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch).with_markdown_cache,
+ @merge_request.recent_commits(load_from_gitaly: true).with_latest_pipeline(@merge_request.source_branch).with_markdown_cache,
commits_count: @merge_request.commits_count
)
@@ -372,7 +378,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
IssuableExportCsvWorker.perform_async(:merge_request, current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
index_path = project_merge_requests_path(project)
- message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email }
+ message = _('Your CSV export has started. It will be emailed to %{email} when complete.') % { email: current_user.notification_email_or_default }
redirect_to(index_path, notice: message)
end
diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb
index 15dc11f5df8..5de71466c10 100644
--- a/app/controllers/projects/packages/packages_controller.rb
+++ b/app/controllers/projects/packages/packages_controller.rb
@@ -9,8 +9,6 @@ module Projects
def show
@package = project.packages.find(params[:id])
- @package_files = @package.package_files.recent
- @maven_metadatum = @package.maven_metadatum
end
end
end
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index 006cb8a2201..4af7508b935 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -10,10 +10,6 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
before_action :authorize_update_pipeline_schedule!, except: [:index, :new, :create, :play]
before_action :authorize_admin_pipeline_schedule!, only: [:destroy]
- before_action do
- push_frontend_feature_flag(:ci_daily_limit_for_pipeline_schedules, @project, default_enabled: :yaml)
- end
-
feature_category :continuous_integration
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index a411264b350..a2312484a9b 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -14,10 +14,6 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action :ensure_pipeline, only: [:show, :downloadable_artifacts]
- before_action do
- push_frontend_feature_flag(:pipeline_source_filter, project, type: :development, default_enabled: :yaml)
- end
-
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
@@ -195,7 +191,8 @@ class Projects::PipelinesController < Projects::ApplicationController
def config_variables
respond_to do |format|
format.json do
- result = Ci::ListConfigVariablesService.new(@project, current_user).execute(params[:sha])
+ project = @project.uses_external_project_ci_config? ? @project.ci_config_external_project : @project
+ result = Ci::ListConfigVariablesService.new(project, current_user).execute(params[:sha])
result.nil? ? head(:no_content) : render(json: result)
end
@@ -297,7 +294,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def index_params
- params.permit(:scope, :username, :ref, :status)
+ params.permit(:scope, :username, :ref, :status, :source)
end
def enable_code_quality_walkthrough_experiment
diff --git a/app/controllers/projects/runner_projects_controller.rb b/app/controllers/projects/runner_projects_controller.rb
index 5da81045e02..39db7618db0 100644
--- a/app/controllers/projects/runner_projects_controller.rb
+++ b/app/controllers/projects/runner_projects_controller.rb
@@ -15,7 +15,7 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
path = project_runners_path(project)
if @runner.assign_to(project, current_user)
- redirect_to path
+ redirect_to path, notice: s_('Runners|Runner assigned to project.')
else
assign_to_messages = @runner.errors.messages[:assign_to]
alert = assign_to_messages&.join(',') || 'Failed adding runner to project'
@@ -28,6 +28,6 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
runner_project = project.runner_projects.find(params[:id])
runner_project.destroy
- redirect_to project_runners_path(project), status: :found
+ redirect_to project_runners_path(project), status: :found, notice: s_('Runners|Runner unassigned from project.')
end
end
diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb
index f7c0a54fb9e..1fb07c3a903 100644
--- a/app/controllers/projects/service_desk_controller.rb
+++ b/app/controllers/projects/service_desk_controller.rb
@@ -24,24 +24,31 @@ class Projects::ServiceDeskController < Projects::ApplicationController
private
def setting_params
- params.permit(:issue_template_key, :outgoing_name, :project_key)
+ params.permit(*allowed_update_attributes)
+ end
+
+ def allowed_update_attributes
+ %i(issue_template_key outgoing_name project_key)
+ end
+
+ def service_desk_attributes
+ service_desk_settings = project.service_desk_setting
+
+ {
+ service_desk_address: project.service_desk_address,
+ service_desk_enabled: project.service_desk_enabled,
+ issue_template_key: service_desk_settings&.issue_template_key,
+ template_file_missing: service_desk_settings&.issue_template_missing?,
+ outgoing_name: service_desk_settings&.outgoing_name,
+ project_key: service_desk_settings&.project_key
+ }
end
def json_response
respond_to do |format|
- service_desk_settings = project.service_desk_setting
-
- service_desk_attributes =
- {
- service_desk_address: project.service_desk_address,
- service_desk_enabled: project.service_desk_enabled,
- issue_template_key: service_desk_settings&.issue_template_key,
- template_file_missing: service_desk_settings&.issue_template_missing?,
- outgoing_name: service_desk_settings&.outgoing_name,
- project_key: service_desk_settings&.project_key
- }
-
format.json { render json: service_desk_attributes }
end
end
end
+
+Projects::ServiceDeskController.prepend_mod
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index 0dcaab7160b..c42d382c4bb 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -26,15 +26,15 @@ class Projects::ServicesController < Projects::ApplicationController
attributes = integration_params[:integration]
if use_inherited_settings?(attributes)
- @integration.inherit_from_id = default_integration.id
+ integration.inherit_from_id = default_integration.id
- if saved = @integration.save(context: :manual_change)
- BulkUpdateIntegrationService.new(default_integration, [@integration]).execute
+ if saved = integration.save(context: :manual_change)
+ BulkUpdateIntegrationService.new(default_integration, [integration]).execute
end
else
attributes[:inherit_from_id] = nil
- @integration.attributes = attributes
- saved = @integration.save(context: :manual_change)
+ integration.attributes = attributes
+ saved = integration.save(context: :manual_change)
end
respond_to do |format|
@@ -65,15 +65,15 @@ class Projects::ServicesController < Projects::ApplicationController
private
def redirect_path
- safe_redirect_path(params[:redirect_to]).presence || edit_project_service_path(@project, @integration)
+ safe_redirect_path(params[:redirect_to]).presence || edit_project_service_path(project, integration)
end
def service_test_response
- unless @integration.update(integration_params[:integration])
- return { error: true, message: _('Validations failed.'), service_response: @integration.errors.full_messages.join(','), test_failed: false }
+ unless integration.update(integration_params[:integration])
+ return { error: true, message: _('Validations failed.'), service_response: integration.errors.full_messages.join(','), test_failed: false }
end
- result = ::Integrations::Test::ProjectService.new(@integration, current_user, params[:event]).execute
+ result = ::Integrations::Test::ProjectService.new(integration, current_user, params[:event]).execute
unless result[:success]
return { error: true, message: s_('Integrations|Connection failed. Please check your settings.'), service_response: result[:message].to_s, test_failed: true }
@@ -93,7 +93,7 @@ class Projects::ServicesController < Projects::ApplicationController
end
def integration
- @integration ||= @project.find_or_initialize_integration(params[:id])
+ @integration ||= project.find_or_initialize_integration(params[:id])
end
alias_method :service, :integration
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index e32815b6239..56e201c592f 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -136,6 +136,7 @@ module Projects
error_tracking_setting_attributes: [
:enabled,
+ :integrated,
:api_host,
:token,
project: [:slug, :name, :organization_slug, :organization_name]
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 475c9de2503..6fd4c632dd3 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -15,6 +15,10 @@ class Projects::TreeController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_edit_tree!, only: [:create_dir]
+ before_action do
+ push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml)
+ end
+
feature_category :source_code_management
def show
diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb
new file mode 100644
index 00000000000..179c7fc8db1
--- /dev/null
+++ b/app/controllers/projects/usage_quotas_controller.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class Projects::UsageQuotasController < Projects::ApplicationController
+ before_action :authorize_admin_project!
+ before_action :verify_usage_quotas_enabled!
+
+ layout "project_settings"
+
+ feature_category :utilization
+
+ def index
+ @storage_app_data = {
+ project_path: @project.full_path,
+ usage_quotas_help_page_path: help_page_path('user/usage_quotas'),
+ build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'),
+ packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'),
+ repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'),
+ snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'),
+ wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size')
+ }
+ end
+
+ private
+
+ def verify_usage_quotas_enabled!
+ render_404 unless Feature.enabled?(:project_storage_ui, project&.group, default_enabled: :yaml)
+ end
+end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index d1486f765e4..9ee8847004e 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -5,5 +5,9 @@ class Projects::WikisController < Projects::ApplicationController
alias_method :container, :project
+ before_action do
+ push_frontend_feature_flag(:content_editor_block_tables, @project, default_enabled: :yaml)
+ end
+
feature_category :wiki
end
diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb
new file mode 100644
index 00000000000..1bd2762f277
--- /dev/null
+++ b/app/controllers/projects/work_items_controller.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Projects::WorkItemsController < Projects::ApplicationController
+ before_action do
+ push_frontend_feature_flag(:work_items, project, default_enabled: :yaml)
+ end
+
+ feature_category :not_owned
+
+ def index
+ render_404 unless Feature.enabled?(:work_items, project, default_enabled: :yaml)
+ end
+end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index bdb645e1934..d7c1d87ae4b 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -34,6 +34,7 @@ class ProjectsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml)
end
layout :determine_layout
diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb
deleted file mode 100644
index 3c94bce126c..00000000000
--- a/app/controllers/registrations/experience_levels_controller.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-module Registrations
- class ExperienceLevelsController < ApplicationController
- layout 'minimal'
-
- before_action :ensure_namespace_path_param
-
- feature_category :onboarding
-
- def update
- current_user.experience_level = params[:experience_level]
-
- if current_user.save
- hide_advanced_issues
-
- if learn_gitlab.available?
- redirect_to namespace_project_board_path(params[:namespace_path], learn_gitlab.project, learn_gitlab.board)
- else
- redirect_to group_path(params[:namespace_path])
- end
- else
- render :show
- end
- end
-
- private
-
- def ensure_namespace_path_param
- redirect_to root_path unless params[:namespace_path].present?
- end
-
- def hide_advanced_issues
- return unless current_user.user_preference.novice?
- return unless learn_gitlab.available?
-
- Boards::UpdateService.new(learn_gitlab.project, current_user, label_ids: [learn_gitlab.label.id]).execute(learn_gitlab.board)
- end
-
- def learn_gitlab
- @learn_gitlab ||= LearnGitlab::Project.new(current_user)
- end
- end
-end
diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb
index ced21b8f291..416bbf43464 100644
--- a/app/controllers/registrations/welcome_controller.rb
+++ b/app/controllers/registrations/welcome_controller.rb
@@ -16,7 +16,7 @@ module Registrations
result = ::Users::SignupService.new(current_user, update_params).execute
if result[:status] == :success
- return redirect_to new_users_sign_up_group_path(trial_params) if show_signup_onboarding?
+ return redirect_to experiment(:combined_registration, user: current_user).redirect_path(trial_params) if show_signup_onboarding?
members = current_user.members
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index cc985e84542..fe800de5dd8 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -21,9 +21,10 @@ class RegistrationsController < Devise::RegistrationsController
def create
set_user_state
- accept_pending_invitations
super do |new_user|
+ accept_pending_invitations if new_user.persisted?
+
persist_accepted_terms_if_required(new_user)
set_role_required(new_user)
@@ -146,8 +147,12 @@ class RegistrationsController < Devise::RegistrationsController
resource.persisted? && resource.blocked_pending_approval?
end
+ def sign_up_params_attributes
+ [:username, :email, :name, :first_name, :last_name, :password]
+ end
+
def sign_up_params
- params.require(:user).permit(:username, :email, :name, :first_name, :last_name, :password)
+ params.require(:user).permit(sign_up_params_attributes)
end
def resource_name
@@ -201,6 +206,7 @@ class RegistrationsController < Devise::RegistrationsController
experiment_name = session.delete(:invite_email_experiment_name)
experiment(:invite_email_preview_text, actor: member).track(:accepted) if experiment_name == 'invite_email_preview_text'
+ experiment(:invite_email_from, actor: member).track(:accepted) if experiment_name == 'invite_email_from'
Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s)
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index dbddb35d358..5f1b3750e41 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -11,7 +11,7 @@ class SearchController < ApplicationController
around_action :allow_gitaly_ref_name_caching
- before_action :block_anonymous_global_searches, except: :opensearch
+ before_action :block_anonymous_global_searches, :check_scope_global_search_enabled, except: :opensearch
skip_before_action :authenticate_user!
requires_cross_project_access if: -> do
search_term_present = params[:search].present? || params[:term].present?
@@ -156,6 +156,29 @@ class SearchController < ApplicationController
redirect_to new_user_session_path, alert: _('You must be logged in to search across all of GitLab')
end
+ def check_scope_global_search_enabled
+ return if params[:project_id].present? || params[:group_id].present?
+
+ search_allowed = case params[:scope]
+ when 'blobs'
+ Feature.enabled?(:global_search_code_tab, current_user, type: :ops, default_enabled: true)
+ when 'commits'
+ Feature.enabled?(:global_search_commits_tab, current_user, type: :ops, default_enabled: true)
+ when 'issues'
+ Feature.enabled?(:global_search_issues_tab, current_user, type: :ops, default_enabled: true)
+ when 'merge_requests'
+ Feature.enabled?(:global_search_merge_requests_tab, current_user, type: :ops, default_enabled: true)
+ when 'wiki_blobs'
+ Feature.enabled?(:global_search_wiki_tab, current_user, type: :ops, default_enabled: true)
+ else
+ true
+ end
+
+ return if search_allowed
+
+ redirect_to search_path, alert: _('Global Search is disabled for this scope')
+ end
+
def render_timeout(exception)
raise exception unless action_name.to_sym.in?(RESCUE_FROM_TIMEOUT_ACTIONS)
diff --git a/app/controllers/user_callouts_controller.rb b/app/controllers/user_callouts_controller.rb
index df3e2425e9f..f52a09adf5a 100644
--- a/app/controllers/user_callouts_controller.rb
+++ b/app/controllers/user_callouts_controller.rb
@@ -4,10 +4,6 @@ class UserCalloutsController < ApplicationController
feature_category :navigation
def create
- callout = Users::DismissUserCalloutService.new(
- container: nil, current_user: current_user, params: { feature_name: feature_name }
- ).execute
-
if callout.persisted?
respond_to do |format|
format.json { head :ok }
@@ -21,6 +17,12 @@ class UserCalloutsController < ApplicationController
private
+ def callout
+ Users::DismissUserCalloutService.new(
+ container: nil, current_user: current_user, params: { feature_name: feature_name }
+ ).execute
+ end
+
def feature_name
params.require(:feature_name)
end
diff --git a/app/controllers/users/group_callouts_controller.rb b/app/controllers/users/group_callouts_controller.rb
new file mode 100644
index 00000000000..cc27452e6a3
--- /dev/null
+++ b/app/controllers/users/group_callouts_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Users
+ class GroupCalloutsController < UserCalloutsController
+ private
+
+ def callout
+ Users::DismissGroupCalloutService.new(
+ container: nil, current_user: current_user, params: callout_params
+ ).execute
+ end
+
+ def callout_params
+ params.permit(:group_id).merge(feature_name: feature_name)
+ end
+ end
+end
diff --git a/app/controllers/users/terms_controller.rb b/app/controllers/users/terms_controller.rb
index be670658048..7fbf0faa68b 100644
--- a/app/controllers/users/terms_controller.rb
+++ b/app/controllers/users/terms_controller.rb
@@ -77,3 +77,5 @@ module Users
end
end
end
+
+Users::TermsController.prepend_mod
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 30ccceec1af..26f56307862 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -167,7 +167,7 @@ class UsersController < ApplicationController
private
def user
- @user ||= find_routable!(User, params[:username], request.path_info)
+ @user ||= find_routable!(User, params[:username], request.fullpath)
end
def personal_projects
diff --git a/app/experiments/combined_registration_experiment.rb b/app/experiments/combined_registration_experiment.rb
new file mode 100644
index 00000000000..da699449d77
--- /dev/null
+++ b/app/experiments/combined_registration_experiment.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class CombinedRegistrationExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+ include Rails.application.routes.url_helpers
+
+ def key_for(source, _ = nil)
+ super(source, 'force_company_trial')
+ end
+
+ def redirect_path(trial_params)
+ @trial_params = trial_params
+
+ run
+ end
+
+ def control_behavior
+ new_users_sign_up_group_path(@trial_params)
+ end
+
+ def candidate_behavior
+ new_users_sign_up_groups_project_path
+ end
+end
diff --git a/app/experiments/security_reports_mr_widget_prompt_experiment.rb b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
new file mode 100644
index 00000000000..fa0ba8e24d4
--- /dev/null
+++ b/app/experiments/security_reports_mr_widget_prompt_experiment.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class SecurityReportsMrWidgetPromptExperiment < ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass
+ def publish(_result = nil)
+ super
+
+ publish_to_database
+ end
+
+ # This is a purely client side experiment, and since we don't have a nicer
+ # way to define variants yet, we define them here.
+ def candidate_behavior
+ end
+end
diff --git a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt
index 70a32ba6f03..bbe8271386f 100644
--- a/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt
+++ b/app/experiments/templates/new_project_readme_content/readme_advanced.md.tt
@@ -38,6 +38,8 @@ Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](<%= redirect("https://docs.gitlab.com/ee/ci/quick_start/index.html") %>)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](<%= redirect("https://docs.gitlab.com/ee/user/application_security/sast/") %>)
+- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](<%= redirect("https://docs.gitlab.com/ee/topics/autodevops/requirements.html") %>)
+- [ ] [Use pull-based deployments for improved Kubernetes management](<%= redirect("https://docs.gitlab.com/ee/user/clusters/agent/") %>)
***
diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb
index 157c454183a..a62d47071d4 100644
--- a/app/finders/branches_finder.rb
+++ b/app/finders/branches_finder.rb
@@ -15,6 +15,10 @@ class BranchesFinder < GitRefsFinder
end
end
+ def total
+ repository.branch_count
+ end
+
private
def names
diff --git a/app/finders/ci/pipelines_finder.rb b/app/finders/ci/pipelines_finder.rb
index a79840216da..39355853d88 100644
--- a/app/finders/ci/pipelines_finder.rb
+++ b/app/finders/ci/pipelines_finder.rb
@@ -25,12 +25,10 @@ module Ci
items = by_status(items)
items = by_ref(items)
items = by_sha(items)
- items = by_name(items)
items = by_username(items)
items = by_yaml_errors(items)
items = by_updated_at(items)
-
- items = by_source(items) if Feature.enabled?(:pipeline_source_filter, project, default_enabled: :yaml)
+ items = by_source(items)
sort_items(items)
end
@@ -116,17 +114,6 @@ module Ci
end
# rubocop: enable CodeReuse/ActiveRecord
- # This method is deprecated and will be removed in 14.3
- # rubocop: disable CodeReuse/ActiveRecord
- def by_name(items)
- if params[:name].present?
- items.joins(:user).where(users: { name: params[:name] })
- else
- items
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
# rubocop: disable CodeReuse/ActiveRecord
def by_username(items)
return items unless params[:username].present?
diff --git a/app/finders/ci/pipelines_for_merge_request_finder.rb b/app/finders/ci/pipelines_for_merge_request_finder.rb
index f769da03738..5d794c0903a 100644
--- a/app/finders/ci/pipelines_for_merge_request_finder.rb
+++ b/app/finders/ci/pipelines_for_merge_request_finder.rb
@@ -29,17 +29,19 @@ module Ci
# Fetch all pipelines without permission check.
def all
- strong_memoize(:all_pipelines) do
- next Ci::Pipeline.none unless source_project
-
- pipelines =
- if merge_request.persisted?
- pipelines_using_cte
- else
- triggered_for_branch.for_sha(commit_shas)
- end
-
- sort(pipelines)
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do
+ strong_memoize(:all_pipelines) do
+ next Ci::Pipeline.none unless source_project
+
+ pipelines =
+ if merge_request.persisted?
+ pipelines_using_cte
+ else
+ triggered_for_branch.for_sha(commit_shas)
+ end
+
+ sort(pipelines)
+ end
end
end
diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index d34b3202433..8bc2a47a024 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -7,9 +7,9 @@ module Ci
ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date].freeze
DEFAULT_SORT = 'created_at_desc'
- def initialize(current_user:, group: nil, params:)
+ def initialize(current_user:, params:)
@params = params
- @group = group
+ @group = params.delete(:group)
@current_user = current_user
end
@@ -48,10 +48,16 @@ module Ci
def group_runners
raise Gitlab::Access::AccessDeniedError unless can?(@current_user, :admin_group, @group)
- # Getting all runners from the group itself and all its descendants
- descendant_projects = Project.for_group_and_its_subgroups(@group)
-
- @runners = Ci::Runner.belonging_to_group_or_project(@group.self_and_descendants, descendant_projects)
+ @runners = case @params[:membership]
+ when :direct
+ Ci::Runner.belonging_to_group(@group.id)
+ when :descendants, nil
+ # Getting all runners from the group itself and all its descendant groups/projects
+ descendant_projects = Project.for_group_and_its_subgroups(@group)
+ Ci::Runner.belonging_to_group_or_project(@group.self_and_descendants, descendant_projects)
+ else
+ raise ArgumentError, 'Invalid membership filter'
+ end
end
def filter_by_status!
diff --git a/app/finders/error_tracking/errors_finder.rb b/app/finders/error_tracking/errors_finder.rb
index fb2d4b14dfa..d83a0c487e6 100644
--- a/app/finders/error_tracking/errors_finder.rb
+++ b/app/finders/error_tracking/errors_finder.rb
@@ -13,9 +13,10 @@ module ErrorTracking
collection = project.error_tracking_errors
collection = by_status(collection)
+ collection = sort(collection)
- # Limit collection until pagination implemented
- collection.limit(20)
+ # Limit collection until pagination implemented.
+ limit(collection)
end
private
@@ -33,5 +34,14 @@ module ErrorTracking
def authorized?
Ability.allowed?(current_user, :read_sentry_issue, project)
end
+
+ def sort(collection)
+ params[:sort] ? collection.sort_by_attribute(params[:sort]) : collection.order_id_desc
+ end
+
+ def limit(collection)
+ # Restrict the maximum limit at 100 records.
+ collection.limit([(params[:limit] || 20).to_i, 100].min)
+ end
end
end
diff --git a/app/finders/groups/user_groups_finder.rb b/app/finders/groups/user_groups_finder.rb
new file mode 100644
index 00000000000..5946e3a8933
--- /dev/null
+++ b/app/finders/groups/user_groups_finder.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+# Groups::UserGroupsFinder
+#
+# Used to filter Groups where a user is member
+#
+# Arguments:
+# current_user - user requesting group info on target user
+# target_user - user for which groups will be found
+# params:
+# permissions: string (see Types::Groups::UserPermissionsEnum)
+# search: string used for search on path and group name
+#
+# Initially created to filter user groups and descendants where the user can create projects
+module Groups
+ class UserGroupsFinder
+ def initialize(current_user, target_user, params = {})
+ @current_user = current_user
+ @target_user = target_user
+ @params = params
+ end
+
+ def execute
+ return Group.none unless current_user&.can?(:read_user_groups, target_user)
+ return Group.none if target_user.blank?
+
+ items = by_permission_scope
+ items = by_search(items)
+
+ sort(items)
+ end
+
+ private
+
+ attr_reader :current_user, :target_user, :params
+
+ def sort(items)
+ items.order(path: :asc, id: :asc) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def by_search(items)
+ return items if params[:search].blank?
+
+ items.search(params[:search])
+ end
+
+ def by_permission_scope
+ if permission_scope_create_projects?
+ target_user.manageable_groups(include_groups_with_developer_maintainer_access: true)
+ else
+ target_user.groups
+ end
+ end
+
+ def permission_scope_create_projects?
+ params[:permission_scope] == :create_projects &&
+ Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml)
+ end
+ end
+end
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 9f3ca385d93..cf706a8f98e 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -41,7 +41,6 @@ class IssuableFinder
include FinderMethods
include CreatedAtFilter
include Gitlab::Utils::StrongMemoize
- prepend OptimizedIssuableLabelFilter
requires_cross_project_access unless: -> { params.project? }
@@ -149,7 +148,6 @@ class IssuableFinder
# Negates all params found in `negatable_params`
def filter_negated_items(items)
- items = by_negated_label(items)
items = by_negated_milestone(items)
items = by_negated_release(items)
items = by_negated_my_reaction_emoji(items)
@@ -172,29 +170,19 @@ class IssuableFinder
count_params = params.merge(state: nil, sort: nil, force_cte: true)
finder = self.class.new(current_user, count_params)
+ state_counts = finder
+ .execute
+ .reorder(nil)
+ .group(:state_id)
+ .count
+
counts = Hash.new(0)
- # Searching by label includes a GROUP BY in the query, but ours will be last
- # because it is added last. Searching by multiple labels also includes a row
- # per issuable, so we have to count those in Ruby - which is bad, but still
- # better than performing multiple queries.
- #
- # This does not apply when we are using a CTE for the search, as the labels
- # GROUP BY is inside the subquery in that case, so we set labels_count to 1.
- #
- # Groups and projects have separate feature flags to suggest the use
- # of a CTE. The CTE will not be used if the sort doesn't support it,
- # but will always be used for the counts here as we ignore sorting
- # anyway.
- labels_count = params.label_names.any? ? params.label_names.count : 1
- labels_count = 1 if use_cte_for_search?
-
- finder.execute.reorder(nil).group(:state_id).count.each do |key, value|
- counts[count_key(key)] += value / labels_count
+ state_counts.each do |key, value|
+ counts[count_key(key)] += value
end
counts[:all] = counts.values.sum
-
counts.with_indifferent_access
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -332,6 +320,7 @@ class IssuableFinder
def by_search(items)
return items unless search
return items if items.is_a?(ActiveRecord::NullRelation)
+ return items if Feature.enabled?(:disable_anonymous_search, type: :ops) && current_user.nil?
if use_cte_for_search?
cte = Gitlab::SQL::CTE.new(klass.table_name, items)
@@ -359,7 +348,7 @@ class IssuableFinder
def sort(items)
# Ensure we always have an explicit sort order (instead of inheriting
# multiple orders when combining ActiveRecord::Relation objects).
- params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: params.label_names) : items.reorder(id: :desc)
+ params[:sort] ? items.sort_by_attribute(params[:sort], excluded_labels: label_filter.label_names_excluded_from_priority_sort) : items.reorder(id: :desc)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -383,6 +372,20 @@ class IssuableFinder
end
end
+ def by_label(items)
+ label_filter.filter(items)
+ end
+
+ def label_filter
+ strong_memoize(:label_filter) do
+ Issuables::LabelFilter.new(
+ params: original_params,
+ project: params.project,
+ group: params.group
+ )
+ end
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_milestone(items)
return items unless params.milestones?
@@ -435,24 +438,6 @@ class IssuableFinder
items.without_particular_release(not_params[:release_tag], not_params[:project_id])
end
- def by_label(items)
- return items unless params.labels?
-
- if params.filter_by_no_label?
- items.without_label
- elsif params.filter_by_any_label?
- items.any_label(params[:sort])
- else
- items.with_label(params.label_names, params[:sort])
- end
- end
-
- def by_negated_label(items)
- return items unless not_params.labels?
-
- items.without_particular_labels(not_params.label_names)
- end
-
def by_my_reaction_emoji(items)
return items unless params[:my_reaction_emoji] && current_user
diff --git a/app/finders/issuable_finder/params.rb b/app/finders/issuable_finder/params.rb
index 595f4e4cf8a..359a56bd39b 100644
--- a/app/finders/issuable_finder/params.rb
+++ b/app/finders/issuable_finder/params.rb
@@ -29,20 +29,6 @@ class IssuableFinder
params.present?
end
- def filter_by_no_label?
- downcased = label_names.map(&:downcase)
-
- downcased.include?(FILTER_NONE)
- end
-
- def filter_by_any_label?
- label_names.map(&:downcase).include?(FILTER_ANY)
- end
-
- def labels?
- params[:label_name].present?
- end
-
def milestones?
params[:milestone_title].present? || params[:milestone_wildcard_id].present?
end
@@ -160,24 +146,6 @@ class IssuableFinder
end
end
- def label_names
- if labels?
- params[:label_name].is_a?(String) ? params[:label_name].split(',') : params[:label_name]
- else
- []
- end
- end
-
- def labels
- strong_memoize(:labels) do
- if labels? && !filter_by_no_label?
- LabelsFinder.new(current_user, project_ids: projects, title: label_names).execute(skip_authorization: true) # rubocop: disable CodeReuse/Finder
- else
- Label.none
- end
- end
- end
-
def milestones
strong_memoize(:milestones) do
if milestones?
diff --git a/app/finders/issuables/label_filter.rb b/app/finders/issuables/label_filter.rb
new file mode 100644
index 00000000000..2bbc963aa90
--- /dev/null
+++ b/app/finders/issuables/label_filter.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+module Issuables
+ class LabelFilter < BaseFilter
+ include Gitlab::Utils::StrongMemoize
+ extend Gitlab::Cache::RequestCache
+
+ def initialize(project:, group:, **kwargs)
+ @project = project
+ @group = group
+
+ super(**kwargs)
+ end
+
+ def filter(issuables)
+ filtered = by_label(issuables)
+ by_negated_label(filtered)
+ end
+
+ def label_names_excluded_from_priority_sort
+ label_names_from_params
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def by_label(issuables)
+ return issuables unless label_names_from_params.present?
+
+ target_model = issuables.model
+
+ if filter_by_no_label?
+ issuables.where(label_link_query(target_model).arel.exists.not)
+ elsif filter_by_any_label?
+ issuables.where(label_link_query(target_model).arel.exists)
+ else
+ issuables_with_selected_labels(issuables, label_names_from_params)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def by_negated_label(issuables)
+ return issuables unless label_names_from_not_params.present?
+
+ issuables_without_selected_labels(issuables, label_names_from_not_params)
+ end
+
+ def filter_by_no_label?
+ label_names_from_params.map(&:downcase).include?(FILTER_NONE)
+ end
+
+ def filter_by_any_label?
+ label_names_from_params.map(&:downcase).include?(FILTER_ANY)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def issuables_with_selected_labels(issuables, label_names)
+ target_model = issuables.model
+
+ if root_namespace
+ all_label_ids = find_label_ids(label_names)
+ # Found less labels in the DB than we were searching for. Return nothing.
+ return issuables.none if all_label_ids.size != label_names.size
+
+ all_label_ids.each do |label_ids|
+ issuables = issuables.where(label_link_query(target_model, label_ids: label_ids).arel.exists)
+ end
+ else
+ label_names.each do |label_name|
+ issuables = issuables.where(label_link_query(target_model, label_names: label_name).arel.exists)
+ end
+ end
+
+ issuables
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def issuables_without_selected_labels(issuables, label_names)
+ target_model = issuables.model
+
+ if root_namespace
+ label_ids = find_label_ids(label_names).flatten(1)
+
+ issuables.where(label_link_query(target_model, label_ids: label_ids).arel.exists.not)
+ else
+ issuables.where(label_link_query(target_model, label_names: label_names).arel.exists.not)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_label_ids(label_names)
+ group_labels = Label
+ .where(project_id: nil)
+ .where(title: label_names)
+ .where(group_id: root_namespace.self_and_descendant_ids)
+
+ project_labels = Label
+ .where(group_id: nil)
+ .where(title: label_names)
+ .where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
+
+ Label
+ .from_union([group_labels, project_labels], remove_duplicates: false)
+ .reorder(nil)
+ .pluck(:title, :id)
+ .group_by(&:first)
+ .values
+ .map { |labels| labels.map(&:last) }
+ end
+ # Avoid repeating label queries times when the finder is instantiated multiple times during the request.
+ request_cache(:find_label_ids) { root_namespace.id }
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def label_link_query(target_model, label_ids: nil, label_names: nil)
+ relation = LabelLink
+ .where(target_type: target_model.name)
+ .where(LabelLink.arel_table['target_id'].eq(target_model.arel_table['id']))
+
+ relation = relation.where(label_id: label_ids) if label_ids
+ relation = relation.joins(:label).where(labels: { name: label_names }) if label_names
+
+ relation
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def label_names_from_params
+ return if params[:label_name].blank?
+
+ strong_memoize(:label_names_from_params) do
+ split_label_names(params[:label_name])
+ end
+ end
+
+ def label_names_from_not_params
+ return if not_params.blank? || not_params[:label_name].blank?
+
+ strong_memoize(:label_names_from_not_params) do
+ split_label_names(not_params[:label_name])
+ end
+ end
+
+ def split_label_names(label_name_param)
+ label_name_param.is_a?(String) ? label_name_param.split(',') : label_name_param
+ end
+
+ def root_namespace
+ strong_memoize(:root_namespace) do
+ (@project || @group)&.root_ancestor
+ end
+ end
+ end
+end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 7595b1c7a15..abf0c180d6b 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -20,6 +20,7 @@
# sort: string
# my_reaction_emoji: string
# public_only: boolean
+# include_hidden: boolean
# due_date: date or '0', '', 'overdue', 'week', or 'month'
# created_after: datetime
# created_before: datetime
@@ -47,8 +48,6 @@ class IssuesFinder < IssuableFinder
# rubocop: disable CodeReuse/ActiveRecord
def with_confidentiality_access_check
- return Issue.all if params.user_can_see_all_issues?
-
# Only admins can see hidden issues, so for non-admins, we filter out any hidden issues
issues = Issue.without_hidden
@@ -76,7 +75,9 @@ class IssuesFinder < IssuableFinder
private
def init_collection
- if params.public_only?
+ if params.include_hidden?
+ Issue.all
+ elsif params.public_only?
Issue.public_only
else
with_confidentiality_access_check
diff --git a/app/finders/issues_finder/params.rb b/app/finders/issues_finder/params.rb
index 2edd8a6f099..02b89f08f9e 100644
--- a/app/finders/issues_finder/params.rb
+++ b/app/finders/issues_finder/params.rb
@@ -6,6 +6,10 @@ class IssuesFinder
params.fetch(:public_only, false)
end
+ def include_hidden?
+ user_can_see_all_issues?
+ end
+
def filter_by_no_due_date?
due_date? && params[:due_date] == Issue::NoDueDate.name
end
diff --git a/app/finders/packages/helm/package_files_finder.rb b/app/finders/packages/helm/package_files_finder.rb
index ba400b27554..c6504d09dce 100644
--- a/app/finders/packages/helm/package_files_finder.rb
+++ b/app/finders/packages/helm/package_files_finder.rb
@@ -6,6 +6,8 @@ module Packages
DEFAULT_PACKAGE_FILES_COUNT = 20
MAX_PACKAGE_FILES_COUNT = 1000
+ delegate :most_recent!, to: :execute
+
def initialize(project, channel, params = {})
@project = project
@channel = channel
diff --git a/app/finders/packages/helm/packages_finder.rb b/app/finders/packages/helm/packages_finder.rb
new file mode 100644
index 00000000000..c58d9292e9f
--- /dev/null
+++ b/app/finders/packages/helm/packages_finder.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Packages
+ module Helm
+ class PackagesFinder
+ include ::Packages::FinderHelper
+
+ MAX_PACKAGES_COUNT = 300
+
+ def initialize(project, channel)
+ @project = project
+ @channel = channel
+ end
+
+ def execute
+ if @channel.blank? || @project.blank?
+ return ::Packages::Package.none
+ end
+
+ pkg_files = ::Packages::PackageFile.for_helm_with_channel(@project, @channel)
+
+ # we use a subquery to get unique packages and at the same time
+ # order + limit them.
+ ::Packages::Package
+ .limit_recent(MAX_PACKAGES_COUNT)
+ .id_in(pkg_files.select(:package_id))
+ end
+ end
+ end
+end
diff --git a/app/finders/packages/npm/package_finder.rb b/app/finders/packages/npm/package_finder.rb
index 92ceac297ee..a367fda37de 100644
--- a/app/finders/packages/npm/package_finder.rb
+++ b/app/finders/packages/npm/package_finder.rb
@@ -5,18 +5,23 @@ module Packages
delegate :find_by_version, to: :execute
delegate :last, to: :execute
- def initialize(package_name, project: nil, namespace: nil)
+ # /!\ CAUTION: don't use last_of_each_version: false with find_by_version. Ordering is not
+ # guaranteed!
+ def initialize(package_name, project: nil, namespace: nil, last_of_each_version: true)
@package_name = package_name
@project = project
@namespace = namespace
+ @last_of_each_version = last_of_each_version
end
def execute
- base.npm
- .with_name(@package_name)
- .installable
- .last_of_each_version
- .preload_files
+ result = base.npm
+ .with_name(@package_name)
+ .installable
+
+ return result unless @last_of_each_version
+
+ result.last_of_each_version
end
private
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index dca3d12f3c9..5537058cc79 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -180,12 +180,20 @@ class ProjectsFinder < UnionFinder
# rubocop: enable CodeReuse/ActiveRecord
def by_topics(items)
- params[:topic].present? ? items.tagged_with(params[:topic]) : items
+ return items unless params[:topic].present?
+
+ topics = params[:topic].instance_of?(String) ? params[:topic].strip.split(/\s*,\s*/) : params[:topic]
+ topics.each do |topic|
+ items = items.with_topic(topic)
+ end
+
+ items
end
def by_search(items)
params[:search] ||= params[:name]
+ return items if Feature.enabled?(:disable_anonymous_project_search, type: :ops) && current_user.nil?
return items.none if params[:search].present? && params[:minimum_search_length].present? && params[:search].length < params[:minimum_search_length].to_i
items.optionally_search(params[:search], include_namespace: params[:search_namespaces].present?)
diff --git a/app/finders/repositories/tree_finder.rb b/app/finders/repositories/tree_finder.rb
new file mode 100644
index 00000000000..2ea5a8856ec
--- /dev/null
+++ b/app/finders/repositories/tree_finder.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Repositories
+ class TreeFinder < GitRefsFinder
+ attr_reader :user_project
+
+ CommitMissingError = Class.new(StandardError)
+
+ def initialize(user_project, params = {})
+ super(user_project.repository, params)
+
+ @user_project = user_project
+ end
+
+ def execute(gitaly_pagination: false)
+ raise CommitMissingError unless commit_exists?
+
+ request_params = { recursive: recursive }
+ request_params[:pagination_params] = pagination_params if gitaly_pagination
+ tree = user_project.repository.tree(commit.id, path, **request_params)
+
+ tree.sorted_entries
+ end
+
+ def total
+ # This is inefficient and we'll look at replacing this implementation
+ Gitlab::Cache.fetch_once([user_project, repository.commit, :tree_size, commit.id, path, recursive]) do
+ user_project.repository.tree(commit.id, path, recursive: recursive).entries.size
+ end
+ end
+
+ def commit_exists?
+ commit.present?
+ end
+
+ private
+
+ def commit
+ @commit ||= user_project.commit(ref)
+ end
+
+ def ref
+ params[:ref] || user_project.default_branch
+ end
+
+ def path
+ params[:path]
+ end
+
+ def recursive
+ params[:recursive]
+ end
+
+ def pagination_params
+ {
+ limit: params[:per_page] || Kaminari.config.default_per_page,
+ page_token: params[:page_token]
+ }
+ end
+ end
+end
diff --git a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
index d943816089f..c4f91d0c15c 100644
--- a/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
+++ b/app/graphql/mutations/admin/sidekiq_queues/delete_jobs.rb
@@ -8,13 +8,18 @@ module Mutations
ADMIN_MESSAGE = 'You must be an admin to use this mutation'
- Gitlab::ApplicationContext::KNOWN_KEYS.each do |key|
+ ::Gitlab::ApplicationContext::KNOWN_KEYS.each do |key|
argument key,
GraphQL::Types::String,
required: false,
- description: "Delete jobs matching #{key} in the context metadata"
+ description: "Delete jobs matching #{key} in the context metadata."
end
+ argument ::Gitlab::SidekiqQueue::WORKER_KEY,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Delete jobs with the given worker class.'
+
argument :queue_name,
GraphQL::Types::String,
required: true,
diff --git a/app/graphql/mutations/custom_emoji/destroy.rb b/app/graphql/mutations/custom_emoji/destroy.rb
new file mode 100644
index 00000000000..863b8152cc7
--- /dev/null
+++ b/app/graphql/mutations/custom_emoji/destroy.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Mutations
+ module CustomEmoji
+ class Destroy < BaseMutation
+ graphql_name 'DestroyCustomEmoji'
+
+ authorize :delete_custom_emoji
+
+ field :custom_emoji,
+ Types::CustomEmojiType,
+ null: true,
+ description: 'Deleted custom emoji.'
+
+ argument :id, ::Types::GlobalIDType[::CustomEmoji],
+ required: true,
+ description: 'Global ID of the custom emoji to destroy.'
+
+ def resolve(id:)
+ custom_emoji = authorized_find!(id: id)
+
+ custom_emoji.destroy!
+
+ {
+ custom_emoji: custom_emoji
+ }
+ end
+
+ private
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::CustomEmoji)
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/customer_relations/organizations/create.rb b/app/graphql/mutations/customer_relations/organizations/create.rb
new file mode 100644
index 00000000000..3fa7b0327ca
--- /dev/null
+++ b/app/graphql/mutations/customer_relations/organizations/create.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Mutations
+ module CustomerRelations
+ module Organizations
+ class Create < BaseMutation
+ include ResolvesIds
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ graphql_name 'CustomerRelationsOrganizationCreate'
+
+ field :organization,
+ Types::CustomerRelations::OrganizationType,
+ null: true,
+ description: 'Organization after the mutation.'
+
+ argument :group_id, ::Types::GlobalIDType[::Group],
+ required: true,
+ description: 'Group for the organization.'
+
+ argument :name,
+ GraphQL::Types::String,
+ required: true,
+ description: 'Name of the organization.'
+
+ argument :default_rate,
+ GraphQL::Types::Float,
+ required: false,
+ description: 'Standard billing rate for the organization.'
+
+ argument :description,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Description or notes for the organization.'
+
+ authorize :admin_organization
+
+ def resolve(args)
+ group = authorized_find!(id: args[:group_id])
+
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml)
+
+ result = ::CustomerRelations::Organizations::CreateService.new(group: group, current_user: current_user, params: args).execute
+ { organization: result.payload, errors: result.errors }
+ end
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id, expected_type: ::Group)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/customer_relations/organizations/update.rb b/app/graphql/mutations/customer_relations/organizations/update.rb
new file mode 100644
index 00000000000..c6ae62193f9
--- /dev/null
+++ b/app/graphql/mutations/customer_relations/organizations/update.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+module Mutations
+ module CustomerRelations
+ module Organizations
+ class Update < Mutations::BaseMutation
+ include ResolvesIds
+
+ graphql_name 'CustomerRelationsOrganizationUpdate'
+
+ authorize :admin_organization
+
+ field :organization,
+ Types::CustomerRelations::OrganizationType,
+ null: false,
+ description: 'Organization after the mutation.'
+
+ argument :id, ::Types::GlobalIDType[::CustomerRelations::Organization],
+ required: true,
+ description: 'Global ID of the organization.'
+
+ argument :name,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Name of the organization.'
+
+ argument :default_rate,
+ GraphQL::Types::Float,
+ required: false,
+ description: 'Standard billing rate for the organization.'
+
+ argument :description,
+ GraphQL::Types::String,
+ required: false,
+ description: 'Description or notes for the organization.'
+
+ def resolve(args)
+ organization = ::Gitlab::Graphql::Lazy.force(GitlabSchema.object_from_id(args.delete(:id), expected_type: ::CustomerRelations::Organization))
+ raise_resource_not_available_error! unless organization
+
+ group = organization.group
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless Feature.enabled?(:customer_relations, group, default_enabled: :yaml)
+
+ authorize!(group)
+
+ result = ::CustomerRelations::Organizations::UpdateService.new(group: group, current_user: current_user, params: args).execute(organization)
+ { organization: result.payload, errors: result.errors }
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb b/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb
new file mode 100644
index 00000000000..a5eb114b2da
--- /dev/null
+++ b/app/graphql/mutations/dependency_proxy/image_ttl_group_policy/update.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Mutations
+ module DependencyProxy
+ module ImageTtlGroupPolicy
+ class Update < Mutations::BaseMutation
+ include Mutations::ResolvesGroup
+
+ graphql_name 'UpdateDependencyProxyImageTtlGroupPolicy'
+
+ authorize :admin_dependency_proxy
+
+ argument :group_path,
+ GraphQL::Types::ID,
+ required: true,
+ description: 'Group path for the group dependency proxy image TTL policy.'
+
+ argument :enabled,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: copy_field_description(Types::DependencyProxy::ImageTtlGroupPolicyType, :enabled)
+
+ argument :ttl,
+ GraphQL::Types::Int,
+ required: false,
+ description: copy_field_description(Types::DependencyProxy::ImageTtlGroupPolicyType, :ttl)
+
+ field :dependency_proxy_image_ttl_policy,
+ Types::DependencyProxy::ImageTtlGroupPolicyType,
+ null: true,
+ description: 'Group image TTL policy after mutation.'
+
+ def resolve(group_path:, **args)
+ group = authorized_find!(group_path: group_path)
+
+ result = ::DependencyProxy::ImageTtlGroupPolicies::UpdateService
+ .new(container: group, current_user: current_user, params: args)
+ .execute
+
+ {
+ dependency_proxy_image_ttl_policy: result.payload[:dependency_proxy_image_ttl_policy],
+ errors: result.errors
+ }
+ end
+
+ private
+
+ def find_object(group_path:)
+ resolve_group(full_path: group_path)
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/queries/repository/files.query.graphql b/app/graphql/queries/repository/files.query.graphql
index 232d98a932c..a83880ce696 100644
--- a/app/graphql/queries/repository/files.query.graphql
+++ b/app/graphql/queries/repository/files.query.graphql
@@ -23,6 +23,7 @@ query getFiles(
$nextPageCursor: String
) {
project(fullPath: $projectPath) {
+ id
__typename
repository {
__typename
diff --git a/app/graphql/queries/repository/paginated_tree.query.graphql b/app/graphql/queries/repository/paginated_tree.query.graphql
new file mode 100644
index 00000000000..e82bc6d0734
--- /dev/null
+++ b/app/graphql/queries/repository/paginated_tree.query.graphql
@@ -0,0 +1,54 @@
+fragment TreeEntry on Entry {
+ __typename
+ id
+ sha
+ name
+ flatPath
+ type
+}
+
+query getPaginatedTree($projectPath: ID!, $path: String, $ref: String!, $nextPageCursor: String) {
+ project(fullPath: $projectPath) {
+ id
+ __typename
+ repository {
+ __typename
+ paginatedTree(path: $path, ref: $ref, after: $nextPageCursor) {
+ __typename
+ pageInfo {
+ __typename
+ endCursor
+ startCursor
+ hasNextPage
+ }
+ nodes {
+ __typename
+ trees {
+ __typename
+ nodes {
+ ...TreeEntry
+ webPath
+ }
+ }
+ submodules {
+ __typename
+ nodes {
+ ...TreeEntry
+ webUrl
+ treeUrl
+ }
+ }
+ blobs {
+ __typename
+ nodes {
+ ...TreeEntry
+ mode
+ webPath
+ lfsOid
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/graphql/queries/repository/path_last_commit.query.graphql b/app/graphql/queries/repository/path_last_commit.query.graphql
index d845f7c6224..b5c5f653429 100644
--- a/app/graphql/queries/repository/path_last_commit.query.graphql
+++ b/app/graphql/queries/repository/path_last_commit.query.graphql
@@ -1,5 +1,6 @@
query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
project(fullPath: $projectPath) {
+ id
__typename
repository {
__typename
diff --git a/app/graphql/queries/repository/permissions.query.graphql b/app/graphql/queries/repository/permissions.query.graphql
index c0262a882cd..6d2ae362e31 100644
--- a/app/graphql/queries/repository/permissions.query.graphql
+++ b/app/graphql/queries/repository/permissions.query.graphql
@@ -1,5 +1,6 @@
query getPermissions($projectPath: ID!) {
project(fullPath: $projectPath) {
+ id
__typename
userPermissions {
__typename
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 48563633d11..20ed089d159 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -124,6 +124,16 @@ module Resolvers
[args[:iid], args[:iids]].any? ? 0 : 0.01
end
+ def self.before_connection_authorization(&block)
+ @before_connection_authorization_block = block
+ end
+
+ # rubocop: disable Style/TrivialAccessors
+ def self.before_connection_authorization_block
+ @before_connection_authorization_block
+ end
+ # rubocop: enable Style/TrivialAccessors
+
def offset_pagination(relation)
::Gitlab::Graphql::Pagination::OffsetPaginatedRelation.new(relation)
end
diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb
index 25fb35ec74b..7c85dd8fb9b 100644
--- a/app/graphql/resolvers/board_list_issues_resolver.rb
+++ b/app/graphql/resolvers/board_list_issues_resolver.rb
@@ -13,15 +13,29 @@ module Resolvers
alias_method :list, :object
def resolve(**args)
- filter_params = item_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
+ filters = item_filters(args[:filters])
+ mutually_exclusive_milestone_args!(filters)
+
+ filter_params = filters.merge(board_id: list.board.id, id: list.id)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
+ pagination_connections = Gitlab::Graphql::Pagination::Keyset::Connection.new(service.execute)
+
+ ::Boards::Issues::ListService.initialize_relative_positions(list.board, current_user, pagination_connections.items)
- service.execute
+ pagination_connections
end
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681
def self.complexity_multiplier(args)
0.005
end
+
+ private
+
+ def mutually_exclusive_milestone_args!(filters)
+ if filters[:milestone_title] && filters[:milestone_wildcard_id]
+ raise ::Gitlab::Graphql::Errors::ArgumentError, 'Incompatible arguments: milestoneTitle, milestoneWildcardId.'
+ end
+ end
end
end
diff --git a/app/graphql/resolvers/ci/group_runners_resolver.rb b/app/graphql/resolvers/ci/group_runners_resolver.rb
new file mode 100644
index 00000000000..e9c399d3855
--- /dev/null
+++ b/app/graphql/resolvers/ci/group_runners_resolver.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ci
+ class GroupRunnersResolver < RunnersResolver
+ type Types::Ci::RunnerType.connection_type, null: true
+
+ argument :membership, ::Types::Ci::RunnerMembershipFilterEnum,
+ required: false,
+ default_value: :descendants,
+ description: 'Control which runners to include in the results.'
+
+ protected
+
+ def runners_finder_params(params)
+ super(params).merge(membership: params[:membership])
+ end
+
+ def parent_param
+ raise 'Expected group missing' unless parent.is_a?(Group)
+
+ { group: parent }
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb
index 1957c4ec058..07105701daa 100644
--- a/app/graphql/resolvers/ci/runners_resolver.rb
+++ b/app/graphql/resolvers/ci/runners_resolver.rb
@@ -34,7 +34,7 @@ module Resolvers
.execute)
end
- private
+ protected
def runners_finder_params(params)
{
@@ -47,6 +47,19 @@ module Resolvers
tag_name: node_selection&.selects?(:tag_list)
}
}.compact
+ .merge(parent_param)
+ end
+
+ def parent_param
+ return {} unless parent
+
+ raise "Unexpected parent type: #{parent.class}"
+ end
+
+ private
+
+ def parent
+ object.respond_to?(:sync) ? object.sync : object
end
end
end
diff --git a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
index 8d77c0f3a8d..9de36b5b7d1 100644
--- a/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
+++ b/app/graphql/resolvers/concerns/issue_resolver_arguments.rb
@@ -30,7 +30,7 @@ module IssueResolverArguments
description: 'Usernames of users assigned to the issue.'
argument :assignee_id, GraphQL::Types::String,
required: false,
- description: 'ID of a user assigned to the issues, "none" and "any" values are supported.'
+ description: 'ID of a user assigned to the issues. Wildcard values "NONE" and "ANY" are supported.'
argument :created_before, Types::TimeType,
required: false,
description: 'Issues created before this date.'
@@ -59,6 +59,9 @@ module IssueResolverArguments
argument :milestone_wildcard_id, ::Types::MilestoneWildcardIdEnum,
required: false,
description: 'Filter issues by milestone ID wildcard.'
+ argument :my_reaction_emoji, GraphQL::Types::String,
+ required: false,
+ description: 'Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported.'
argument :not, Types::Issues::NegatedIssueFilterInputType,
description: 'Negated arguments.',
prepare: ->(negated_args, ctx) { negated_args.to_h },
diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb
index 77f2105db7c..7fb0852b11e 100644
--- a/app/graphql/resolvers/concerns/resolves_pipelines.rb
+++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb
@@ -17,6 +17,11 @@ module ResolvesPipelines
GraphQL::Types::String,
required: false,
description: "Filter pipelines by the sha of the commit they are run for."
+
+ argument :source,
+ GraphQL::Types::String,
+ required: false,
+ description: "Filter pipelines by their source. Will be ignored if `dast_view_scans` feature flag is disabled."
end
class_methods do
@@ -30,6 +35,8 @@ module ResolvesPipelines
end
def resolve_pipelines(project, params = {})
+ params.delete(:source) unless Feature.enabled?(:dast_view_scans, project, default_enabled: :yaml)
+
Ci::PipelinesFinder.new(project, context[:current_user], params).execute
end
end
diff --git a/app/graphql/resolvers/labels_resolver.rb b/app/graphql/resolvers/labels_resolver.rb
index 505d1dff8d2..f0e099e8fb2 100644
--- a/app/graphql/resolvers/labels_resolver.rb
+++ b/app/graphql/resolvers/labels_resolver.rb
@@ -10,7 +10,7 @@ module Resolvers
argument :search_term, GraphQL::Types::String,
required: false,
- description: 'A search term to find labels with.'
+ description: 'Search term to find labels with.'
argument :include_ancestor_groups, GraphQL::Types::Boolean,
required: false,
diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index 8f2c7847a2e..c0dd61078c6 100644
--- a/app/graphql/resolvers/merge_requests_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -49,7 +49,7 @@ module Resolvers
argument :state, ::Types::MergeRequestStateEnum,
required: false,
- description: 'A merge request state. If provided, all resolved merge requests will have this state.'
+ description: 'Merge request state. If provided, all resolved merge requests will have this state.'
argument :labels, [GraphQL::Types::String],
required: false,
diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb
index 84f7d66ec19..dc6d781f584 100644
--- a/app/graphql/resolvers/milestones_resolver.rb
+++ b/app/graphql/resolvers/milestones_resolver.rb
@@ -15,15 +15,15 @@ module Resolvers
argument :title, GraphQL::Types::String,
required: false,
- description: 'The title of the milestone.'
+ description: 'Title of the milestone.'
argument :search_title, GraphQL::Types::String,
required: false,
- description: 'A search string for the title.'
+ description: 'Search string for the title.'
argument :containing_date, Types::TimeType,
required: false,
- description: 'A date that the milestone contains.'
+ description: 'Date the milestone contains.'
argument :sort, Types::MilestoneSortEnum,
description: 'Sort milestones by this criteria.',
diff --git a/app/graphql/resolvers/package_details_resolver.rb b/app/graphql/resolvers/package_details_resolver.rb
index 89d79747732..42cb23e701d 100644
--- a/app/graphql/resolvers/package_details_resolver.rb
+++ b/app/graphql/resolvers/package_details_resolver.rb
@@ -6,7 +6,7 @@ module Resolvers
argument :id, ::Types::GlobalIDType[::Packages::Package],
required: true,
- description: 'The global ID of the package.'
+ description: 'Global ID of the package.'
def ready?(**args)
context[self.class] ||= { executions: 0 }
diff --git a/app/graphql/resolvers/paginated_tree_resolver.rb b/app/graphql/resolvers/paginated_tree_resolver.rb
index d1b4e75169c..6c0545d26de 100644
--- a/app/graphql/resolvers/paginated_tree_resolver.rb
+++ b/app/graphql/resolvers/paginated_tree_resolver.rb
@@ -10,11 +10,11 @@ module Resolvers
argument :path, GraphQL::Types::String,
required: false,
default_value: '', # root of the repository
- description: 'The path to get the tree for. Default value is the root of the repository.'
+ description: 'Path to get the tree for. Default value is the root of the repository.'
argument :ref, GraphQL::Types::String,
required: false,
default_value: :head,
- description: 'The commit ref to get the tree for. Default value is HEAD.'
+ description: 'Commit ref to get the tree for. Default value is HEAD.'
argument :recursive, GraphQL::Types::Boolean,
required: false,
default_value: false,
diff --git a/app/graphql/resolvers/release_resolver.rb b/app/graphql/resolvers/release_resolver.rb
index 0374a1103de..82b5647615e 100644
--- a/app/graphql/resolvers/release_resolver.rb
+++ b/app/graphql/resolvers/release_resolver.rb
@@ -6,7 +6,7 @@ module Resolvers
argument :tag_name, GraphQL::Types::String,
required: true,
- description: 'The name of the tag associated to the release.'
+ description: 'Name of the tag associated to the release.'
alias_method :project, :object
diff --git a/app/graphql/resolvers/repository_branch_names_resolver.rb b/app/graphql/resolvers/repository_branch_names_resolver.rb
index e9aacda2652..96550bce32f 100644
--- a/app/graphql/resolvers/repository_branch_names_resolver.rb
+++ b/app/graphql/resolvers/repository_branch_names_resolver.rb
@@ -8,15 +8,15 @@ module Resolvers
argument :search_pattern, GraphQL::Types::String,
required: true,
- description: 'The pattern to search for branch names by.'
+ description: 'Pattern to search for branch names by.'
argument :offset, GraphQL::Types::Int,
required: true,
- description: 'The number of branch names to skip.'
+ description: 'Number of branch names to skip.'
argument :limit, GraphQL::Types::Int,
required: true,
- description: 'The number of branch names to return.'
+ description: 'Number of branch names to return.'
def resolve(search_pattern:, offset:, limit:)
Repositories::BranchNamesFinder.new(object, offset: offset, limit: limit, search: search_pattern).execute
diff --git a/app/graphql/resolvers/snippets_resolver.rb b/app/graphql/resolvers/snippets_resolver.rb
index 7d18c9c6fea..149bd8fa1ce 100644
--- a/app/graphql/resolvers/snippets_resolver.rb
+++ b/app/graphql/resolvers/snippets_resolver.rb
@@ -12,15 +12,15 @@ module Resolvers
argument :author_id, ::Types::GlobalIDType[::User],
required: false,
- description: 'The ID of an author.'
+ description: 'ID of an author.'
argument :project_id, ::Types::GlobalIDType[::Project],
required: false,
- description: 'The ID of a project.'
+ description: 'ID of a project.'
argument :type, Types::Snippets::TypeEnum,
required: false,
- description: 'The type of snippet.'
+ description: 'Type of snippet.'
argument :explore,
GraphQL::Types::Boolean,
diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb
index 263b190c74e..f0be1b6e9a5 100644
--- a/app/graphql/resolvers/todo_resolver.rb
+++ b/app/graphql/resolvers/todo_resolver.rb
@@ -8,27 +8,34 @@ module Resolvers
argument :action, [Types::TodoActionEnum],
required: false,
- description: 'The action to be filtered.'
+ description: 'Action to be filtered.'
argument :author_id, [GraphQL::Types::ID],
required: false,
- description: 'The ID of an author.'
+ description: 'ID of an author.'
argument :project_id, [GraphQL::Types::ID],
required: false,
- description: 'The ID of a project.'
+ description: 'ID of a project.'
argument :group_id, [GraphQL::Types::ID],
required: false,
- description: 'The ID of a group.'
+ description: 'ID of a group.'
argument :state, [Types::TodoStateEnum],
required: false,
- description: 'The state of the todo.'
+ description: 'State of the todo.'
argument :type, [Types::TodoTargetEnum],
required: false,
- description: 'The type of the todo.'
+ description: 'Type of the todo.'
+
+ before_connection_authorization do |nodes, current_user|
+ Preloaders::UserMaxAccessLevelInProjectsPreloader.new(
+ nodes.map(&:project).compact,
+ current_user
+ ).execute
+ end
def resolve(**args)
return Todo.none unless current_user.present? && target.present?
diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb
index 70b4d81845c..8d6ece0956e 100644
--- a/app/graphql/resolvers/tree_resolver.rb
+++ b/app/graphql/resolvers/tree_resolver.rb
@@ -9,11 +9,11 @@ module Resolvers
argument :path, GraphQL::Types::String,
required: false,
default_value: '',
- description: 'The path to get the tree for. Default value is the root of the repository.'
+ description: 'Path to get the tree for. Default value is the root of the repository.'
argument :ref, GraphQL::Types::String,
required: false,
default_value: :head,
- description: 'The commit ref to get the tree for. Default value is HEAD.'
+ description: 'Commit ref to get the tree for. Default value is HEAD.'
argument :recursive, GraphQL::Types::Boolean,
required: false,
default_value: false,
diff --git a/app/graphql/resolvers/users/groups_resolver.rb b/app/graphql/resolvers/users/groups_resolver.rb
new file mode 100644
index 00000000000..0899b08e19c
--- /dev/null
+++ b/app/graphql/resolvers/users/groups_resolver.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Users
+ class GroupsResolver < BaseResolver
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+ include LooksAhead
+
+ type Types::GroupType.connection_type, null: true
+
+ authorize :read_user_groups
+ authorizes_object!
+
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search by group name or path.'
+ argument :permission_scope,
+ ::Types::PermissionTypes::GroupEnum,
+ required: false,
+ description: 'Filter by permissions the user has on groups.'
+
+ before_connection_authorization do |nodes, current_user|
+ Preloaders::UserMaxAccessLevelInGroupsPreloader.new(nodes, current_user).execute
+ end
+
+ def resolve_with_lookahead(**args)
+ return unless Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml)
+
+ apply_lookahead(Groups::UserGroupsFinder.new(current_user, object, args).execute)
+ end
+
+ private
+
+ def preloads
+ {
+ path: [:route],
+ full_path: [:route]
+ }
+ end
+ end
+ end
+end
+
+Resolvers::Users::GroupsResolver.prepend_mod_with('Resolvers::Users::GroupsResolver')
diff --git a/app/graphql/resolvers/users/snippets_resolver.rb b/app/graphql/resolvers/users/snippets_resolver.rb
index ee1727aadbe..75bba8debab 100644
--- a/app/graphql/resolvers/users/snippets_resolver.rb
+++ b/app/graphql/resolvers/users/snippets_resolver.rb
@@ -11,7 +11,7 @@ module Resolvers
argument :type, Types::Snippets::TypeEnum,
required: false,
- description: 'The type of snippet.'
+ description: 'Type of snippet.'
private
diff --git a/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
index c54c938402d..8276549ddcc 100644
--- a/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
+++ b/app/graphql/types/admin/analytics/usage_trends/measurement_type.rb
@@ -12,13 +12,13 @@ module Types
authorize :read_usage_trends_measurement
field :recorded_at, Types::TimeType, null: true,
- description: 'The time the measurement was recorded.'
+ description: 'Time the measurement was recorded.'
field :count, GraphQL::Types::Int, null: false,
description: 'Object count.'
field :identifier, Types::Admin::Analytics::UsageTrends::MeasurementIdentifierEnum, null: false,
- description: 'The type of objects being measured.'
+ description: 'Type of objects being measured.'
end
end
end
diff --git a/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb b/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb
index cc6e3db007b..4f31e2f783a 100644
--- a/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb
+++ b/app/graphql/types/admin/sidekiq_queues/delete_jobs_response_type.rb
@@ -17,12 +17,12 @@ module Types
field :deleted_jobs,
GraphQL::Types::Int,
null: true,
- description: 'The number of matching jobs deleted.'
+ description: 'Number of matching jobs deleted.'
field :queue_size,
GraphQL::Types::Int,
null: true,
- description: 'The queue size after processing.'
+ description: 'Queue size after processing.'
end
end
end
diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb
index bdfdd2c5886..7495d46179c 100644
--- a/app/graphql/types/alert_management/alert_type.rb
+++ b/app/graphql/types/alert_management/alert_type.rb
@@ -115,19 +115,17 @@ module Types
null: true,
description: 'Runbook for the alert as defined in alert details.'
- field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodoResolver do
- extension(::Gitlab::Graphql::TodosProjectPermissionPreloader::FieldExtension)
- end
+ field :todos, description: 'To-do items of the current user for the alert.', resolver: Resolvers::TodoResolver
field :details_url,
GraphQL::Types::String,
null: false,
- description: 'The URL of the alert detail page.'
+ description: 'URL of the alert detail page.'
field :prometheus_alert,
Types::PrometheusAlertType,
null: true,
- description: 'The alert condition for Prometheus.'
+ description: 'Alert condition for Prometheus.'
def notes
object.ordered_notes
diff --git a/app/graphql/types/award_emojis/award_emoji_type.rb b/app/graphql/types/award_emojis/award_emoji_type.rb
index 1f6f0badcac..76415afc6c1 100644
--- a/app/graphql/types/award_emojis/award_emoji_type.rb
+++ b/app/graphql/types/award_emojis/award_emoji_type.rb
@@ -13,32 +13,32 @@ module Types
field :name,
GraphQL::Types::String,
null: false,
- description: 'The emoji name.'
+ description: 'Emoji name.'
field :description,
GraphQL::Types::String,
null: false,
- description: 'The emoji description.'
+ description: 'Emoji description.'
field :unicode,
GraphQL::Types::String,
null: false,
- description: 'The emoji in Unicode.'
+ description: 'Emoji in Unicode.'
field :emoji,
GraphQL::Types::String,
null: false,
- description: 'The emoji as an icon.'
+ description: 'Emoji as an icon.'
field :unicode_version,
GraphQL::Types::String,
null: false,
- description: 'The Unicode version for this emoji.'
+ description: 'Unicode version for this emoji.'
field :user,
Types::UserType,
null: false,
- description: 'The user who awarded the emoji.'
+ description: 'User who awarded the emoji.'
def user
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 75fdb41ceb6..9c27f0f8138 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -9,6 +9,7 @@ module Types
DEFAULT_COMPLEXITY = 1
attr_reader :deprecation, :doc_reference
+ attr_writer :max_page_size # Can be removed with :performance_roadmap feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/337198
def initialize(**kwargs, &block)
@calls_gitaly = !!kwargs.delete(:calls_gitaly)
diff --git a/app/graphql/types/boards/board_issuable_input_base_type.rb b/app/graphql/types/boards/board_issuable_input_base_type.rb
index 326f73846d0..81dd21aebec 100644
--- a/app/graphql/types/boards/board_issuable_input_base_type.rb
+++ b/app/graphql/types/boards/board_issuable_input_base_type.rb
@@ -14,7 +14,7 @@ module Types
argument :my_reaction_emoji, GraphQL::Types::String,
required: false,
- description: 'Filter by reaction emoji applied by the current user.'
+ description: 'Filter by reaction emoji applied by the current user. Wildcard values "NONE" and "ANY" are supported.'
end
end
end
diff --git a/app/graphql/types/boards/board_issue_input_base_type.rb b/app/graphql/types/boards/board_issue_input_base_type.rb
index 82db1802b81..e12ed32a250 100644
--- a/app/graphql/types/boards/board_issue_input_base_type.rb
+++ b/app/graphql/types/boards/board_issue_input_base_type.rb
@@ -24,6 +24,10 @@ module Types
as: :issue_types,
description: 'Filter by the given issue types.',
required: false
+
+ argument :milestone_wildcard_id, ::Types::MilestoneWildcardIdEnum,
+ required: false,
+ description: 'Filter by milestone ID wildcard.'
end
end
end
diff --git a/app/graphql/types/ci/config/job_restriction_type.rb b/app/graphql/types/ci/config/job_restriction_type.rb
index 891ba18dacc..8cf0e210def 100644
--- a/app/graphql/types/ci/config/job_restriction_type.rb
+++ b/app/graphql/types/ci/config/job_restriction_type.rb
@@ -8,7 +8,7 @@ module Types
graphql_name 'CiConfigJobRestriction'
field :refs, [GraphQL::Types::String], null: true,
- description: 'The Git refs the job restriction applies to.'
+ description: 'Git refs the job restriction applies to.'
end
end
end
diff --git a/app/graphql/types/ci/config/status_enum.rb b/app/graphql/types/ci/config/status_enum.rb
index 1ba207531b8..dbb560c93c3 100644
--- a/app/graphql/types/ci/config/status_enum.rb
+++ b/app/graphql/types/ci/config/status_enum.rb
@@ -7,8 +7,8 @@ module Types
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
+ value 'VALID', 'Configuration file is valid.', value: :valid
+ value 'INVALID', 'Configuration file is not valid.', value: :invalid
end
end
end
diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb
index 4a3518e1865..48bd91bfc5b 100644
--- a/app/graphql/types/ci/job_type.rb
+++ b/app/graphql/types/ci/job_type.rb
@@ -2,9 +2,10 @@
module Types
module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ # The permission is presented through `StageType` that has its own authorization
class JobType < BaseObject
graphql_name 'CiJob'
- authorize :read_commit_status
connection_type_class(Types::CountableConnectionType)
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 0375257eb7b..493ce188d9b 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -97,7 +97,7 @@ module Types
type: ::Types::Ci::JobType,
null: true,
authorize: :read_commit_status,
- description: 'A specific job in this pipeline, either by name or ID.' do
+ description: 'Specific job in this pipeline, either by name or ID.' do
argument :id,
type: ::Types::GlobalIDType[::CommitStatus],
required: false,
diff --git a/app/graphql/types/ci/runner_membership_filter_enum.rb b/app/graphql/types/ci/runner_membership_filter_enum.rb
new file mode 100644
index 00000000000..2e1051b2151
--- /dev/null
+++ b/app/graphql/types/ci/runner_membership_filter_enum.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Types
+ module Ci
+ class RunnerMembershipFilterEnum < BaseEnum
+ graphql_name 'RunnerMembershipFilter'
+ description 'Values for filtering runners in namespaces.'
+
+ value 'DIRECT',
+ description: "Include runners that have a direct relationship.",
+ value: :direct
+
+ value 'DESCENDANTS',
+ description: "Include runners that have either a direct relationship or a relationship with descendants. These can be project runners or group runners (in the case where group is queried).",
+ value: :descendants
+ end
+ end
+end
diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb
index 63357e2345b..c0d931b3d31 100644
--- a/app/graphql/types/ci/stage_type.rb
+++ b/app/graphql/types/ci/stage_type.rb
@@ -15,9 +15,8 @@ module Types
description: 'Group of jobs for the stage.'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the stage.'
- field :jobs, Ci::JobType.connection_type, null: true,
- description: 'Jobs for the stage.',
- method: 'latest_statuses'
+ field :jobs, Types::Ci::JobType.connection_type, null: true,
+ description: 'Jobs for the stage.'
field :status, GraphQL::Types::String,
null: true,
description: 'Status of the pipeline stage.'
@@ -48,19 +47,25 @@ module Types
end
end
+ def jobs
+ GraphQL::Pagination::ActiveRecordRelationConnection.new(
+ object.latest_statuses,
+ max_page_size: Gitlab::CurrentSettings.current_application_settings.jobs_per_stage_page_size
+ )
+ end
+
private
# rubocop: disable CodeReuse/ActiveRecord
def jobs_for_pipeline(pipeline, stage_ids, include_needs)
- builds_results = pipeline.latest_builds.where(stage_id: stage_ids).preload(:job_artifacts, :project)
- bridges_results = pipeline.bridges.where(stage_id: stage_ids).preload(:project)
- builds_results = builds_results.preload(:needs) if include_needs
- bridges_results = bridges_results.preload(:needs) if include_needs
- commit_status_results = pipeline.latest_statuses.where(stage_id: stage_ids)
+ jobs = pipeline.statuses.latest.where(stage_id: stage_ids)
+
+ preloaded_relations = [:project, :metadata, :job_artifacts, :downstream_pipeline]
+ preloaded_relations << :needs if include_needs
- results = builds_results | bridges_results | commit_status_results
+ Preloaders::CommitStatusPreloader.new(jobs).execute(preloaded_relations)
- results.group_by(&:stage_id)
+ jobs.group_by(&:stage_id)
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb b/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb
index 76d2a314c13..62bd3e9b2ca 100644
--- a/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb
+++ b/app/graphql/types/ci_configuration/sast/ui_component_size_enum.rb
@@ -7,9 +7,9 @@ module Types
graphql_name 'SastUiComponentSize'
description 'Size of UI component in SAST configuration page'
- value 'SMALL', description: "The size of UI component in SAST configuration page is small."
- value 'MEDIUM', description: "The size of UI component in SAST configuration page is medium."
- value 'LARGE', description: "The size of UI component in SAST configuration page is large."
+ value 'SMALL', description: "Size of UI component in SAST configuration page is small."
+ value 'MEDIUM', description: "Size of UI component in SAST configuration page is medium."
+ value 'LARGE', description: "Size of UI component in SAST configuration page is large."
end
end
end
diff --git a/app/graphql/types/commit_action_type.rb b/app/graphql/types/commit_action_type.rb
index b170134b388..6f6d6a418dc 100644
--- a/app/graphql/types/commit_action_type.rb
+++ b/app/graphql/types/commit_action_type.rb
@@ -3,7 +3,7 @@
module Types
class CommitActionType < BaseInputObject
argument :action, type: Types::CommitActionModeEnum, required: true,
- description: 'The action to perform, create, delete, move, update, chmod.'
+ description: 'Action to perform: create, delete, move, update, or chmod.'
argument :file_path, type: GraphQL::Types::String, required: true,
description: 'Full path to the file.'
argument :content, type: GraphQL::Types::String, required: false,
diff --git a/app/graphql/types/container_repository_cleanup_status_enum.rb b/app/graphql/types/container_repository_cleanup_status_enum.rb
index 6e654e65360..e9ccb8adec8 100644
--- a/app/graphql/types/container_repository_cleanup_status_enum.rb
+++ b/app/graphql/types/container_repository_cleanup_status_enum.rb
@@ -5,9 +5,9 @@ module Types
graphql_name 'ContainerRepositoryCleanupStatus'
description 'Status of the tags cleanup of a container repository'
- value 'UNSCHEDULED', value: 'cleanup_unscheduled', description: 'The tags cleanup is not scheduled. This is the default state.'
- value 'SCHEDULED', value: 'cleanup_scheduled', description: 'The tags cleanup is scheduled and is going to be executed shortly.'
- value 'UNFINISHED', value: 'cleanup_unfinished', description: 'The tags cleanup has been partially executed. There are still remaining tags to delete.'
- value 'ONGOING', value: 'cleanup_ongoing', description: 'The tags cleanup is ongoing.'
+ value 'UNSCHEDULED', value: 'cleanup_unscheduled', description: 'Tags cleanup is not scheduled. This is the default state.'
+ value 'SCHEDULED', value: 'cleanup_scheduled', description: 'Tags cleanup is scheduled and is going to be executed shortly.'
+ value 'UNFINISHED', value: 'cleanup_unfinished', description: 'Tags cleanup has been partially executed. There are still remaining tags to delete.'
+ value 'ONGOING', value: 'cleanup_ongoing', description: 'Tags cleanup is ongoing.'
end
end
diff --git a/app/graphql/types/container_repository_tag_type.rb b/app/graphql/types/container_repository_tag_type.rb
index b6b65bce421..206d6a3426c 100644
--- a/app/graphql/types/container_repository_tag_type.rb
+++ b/app/graphql/types/container_repository_tag_type.rb
@@ -14,7 +14,7 @@ module Types
field :digest, GraphQL::Types::String, null: true, description: 'Digest of the tag.'
field :revision, GraphQL::Types::String, null: true, description: 'Revision of the tag.'
field :short_revision, GraphQL::Types::String, null: true, description: 'Short revision of the tag.'
- field :total_size, GraphQL::Types::BigInt, null: true, description: 'The size of the tag.'
+ field :total_size, GraphQL::Types::BigInt, null: true, description: 'Size of the tag.'
field :created_at, Types::TimeType, null: true, description: 'Timestamp when the tag was created.'
field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete this tag.'
diff --git a/app/graphql/types/container_repository_type.rb b/app/graphql/types/container_repository_type.rb
index 91a65053131..67093f57862 100644
--- a/app/graphql/types/container_repository_type.rb
+++ b/app/graphql/types/container_repository_type.rb
@@ -15,7 +15,7 @@ module Types
field :created_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was created.'
field :updated_at, Types::TimeType, null: false, description: 'Timestamp when the container repository was updated.'
field :expiration_policy_started_at, Types::TimeType, null: true, description: 'Timestamp when the cleanup done by the expiration policy was started on the container repository.'
- field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'The tags cleanup status for the container repository.'
+ field :expiration_policy_cleanup_status, Types::ContainerRepositoryCleanupStatusEnum, null: true, description: 'Tags cleanup status for the container repository.'
field :status, Types::ContainerRepositoryStatusEnum, null: true, description: 'Status of the container repository.'
field :tags_count, GraphQL::Types::Int, null: false, description: 'Number of tags associated with this image.'
field :can_delete, GraphQL::Types::Boolean, null: false, description: 'Can the current user delete the container repository.'
diff --git a/app/graphql/types/custom_emoji_type.rb b/app/graphql/types/custom_emoji_type.rb
index 64381b3ee1e..379a0c44d67 100644
--- a/app/graphql/types/custom_emoji_type.rb
+++ b/app/graphql/types/custom_emoji_type.rb
@@ -9,16 +9,16 @@ module Types
field :id, ::Types::GlobalIDType[::CustomEmoji],
null: false,
- description: 'The ID of the emoji.'
+ description: 'ID of the emoji.'
field :name, GraphQL::Types::String,
null: false,
- description: 'The name of the emoji.'
+ description: 'Name of the emoji.'
field :url, GraphQL::Types::String,
null: false,
method: :file,
- description: 'The link to file of the emoji.'
+ description: 'Link to file of the emoji.'
field :external, GraphQL::Types::Boolean,
null: false,
diff --git a/app/graphql/types/customer_relations/contact_type.rb b/app/graphql/types/customer_relations/contact_type.rb
new file mode 100644
index 00000000000..35b5bf45698
--- /dev/null
+++ b/app/graphql/types/customer_relations/contact_type.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Types
+ module CustomerRelations
+ class ContactType < BaseObject
+ graphql_name 'CustomerRelationsContact'
+
+ authorize :read_contact
+
+ field :id,
+ GraphQL::Types::ID,
+ null: false,
+ description: 'Internal ID of the contact.'
+
+ field :organization, Types::CustomerRelations::OrganizationType,
+ null: true,
+ description: "Organization of the contact."
+
+ field :first_name,
+ GraphQL::Types::String,
+ null: false,
+ description: 'First name of the contact.'
+
+ field :last_name,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Last name of the contact.'
+
+ field :phone,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Phone number of the contact.'
+
+ field :email,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Email address of the contact.'
+
+ field :description,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Description or notes for the contact.'
+
+ field :created_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the contact was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the contact was last updated.'
+ end
+ end
+end
diff --git a/app/graphql/types/customer_relations/organization_type.rb b/app/graphql/types/customer_relations/organization_type.rb
new file mode 100644
index 00000000000..0e091d4a9a3
--- /dev/null
+++ b/app/graphql/types/customer_relations/organization_type.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Types
+ module CustomerRelations
+ class OrganizationType < BaseObject
+ graphql_name 'CustomerRelationsOrganization'
+
+ authorize :read_organization
+
+ field :id,
+ GraphQL::Types::ID,
+ null: false,
+ description: 'Internal ID of the organization.'
+
+ field :name,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Name of the organization.'
+
+ field :default_rate,
+ GraphQL::Types::Float,
+ null: true,
+ description: 'Standard billing rate for the organization.'
+
+ field :description,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Description or notes for the organization.'
+
+ field :created_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the organization was created.'
+
+ field :updated_at,
+ Types::TimeType,
+ null: false,
+ description: 'Timestamp the organization was last updated.'
+ end
+ end
+end
diff --git a/app/graphql/types/dependency_proxy/blob_type.rb b/app/graphql/types/dependency_proxy/blob_type.rb
new file mode 100644
index 00000000000..f5a78fbb3ba
--- /dev/null
+++ b/app/graphql/types/dependency_proxy/blob_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class DependencyProxy::BlobType < BaseObject
+ graphql_name 'DependencyProxyBlob'
+
+ description 'Dependency proxy blob'
+
+ authorize :read_dependency_proxy
+
+ field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
+ field :file_name, GraphQL::Types::String, null: false, description: 'Name of the blob.'
+ field :size, GraphQL::Types::String, null: false, description: 'Size of the blob file.'
+ end
+end
diff --git a/app/graphql/types/dependency_proxy/group_setting_type.rb b/app/graphql/types/dependency_proxy/group_setting_type.rb
new file mode 100644
index 00000000000..8b8b8572aa9
--- /dev/null
+++ b/app/graphql/types/dependency_proxy/group_setting_type.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Types
+ class DependencyProxy::GroupSettingType < BaseObject
+ graphql_name 'DependencyProxySetting'
+
+ description 'Group-level Dependency Proxy settings'
+
+ authorize :read_dependency_proxy
+
+ field :enabled, GraphQL::Types::Boolean, null: false, description: 'Indicates whether the dependency proxy is enabled for the group.'
+ end
+end
diff --git a/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb b/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb
new file mode 100644
index 00000000000..29bba7122d0
--- /dev/null
+++ b/app/graphql/types/dependency_proxy/image_ttl_group_policy_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class DependencyProxy::ImageTtlGroupPolicyType < BaseObject
+ graphql_name 'DependencyProxyImageTtlGroupPolicy'
+
+ description 'Group-level Dependency Proxy TTL policy settings'
+
+ authorize :read_dependency_proxy
+
+ field :enabled, GraphQL::Types::Boolean, null: false, description: 'Indicates whether the policy is enabled or disabled.'
+ field :ttl, GraphQL::Types::Int, null: true, description: 'Number of days to retain a cached image file.'
+ field :created_at, Types::TimeType, null: true, description: 'Timestamp of creation.'
+ field :updated_at, Types::TimeType, null: true, description: 'Timestamp of the most recent update.'
+ end
+end
diff --git a/app/graphql/types/dependency_proxy/manifest_type.rb b/app/graphql/types/dependency_proxy/manifest_type.rb
new file mode 100644
index 00000000000..9aa62266ef7
--- /dev/null
+++ b/app/graphql/types/dependency_proxy/manifest_type.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Types
+ class DependencyProxy::ManifestType < BaseObject
+ graphql_name 'DependencyProxyManifest'
+
+ description 'Dependency proxy manifest'
+
+ authorize :read_dependency_proxy
+
+ field :created_at, Types::TimeType, null: false, description: 'Date of creation.'
+ field :updated_at, Types::TimeType, null: false, description: 'Date of most recent update.'
+ field :file_name, GraphQL::Types::String, null: false, description: 'Name of the manifest.'
+ field :image_name, GraphQL::Types::String, null: false, description: 'Name of the image.'
+ field :size, GraphQL::Types::String, null: false, description: 'Size of the manifest file.'
+ field :digest, GraphQL::Types::String, null: false, description: 'Digest of the manifest.'
+
+ def image_name
+ object.file_name.chomp(File.extname(object.file_name))
+ end
+ end
+end
diff --git a/app/graphql/types/design_management/design_at_version_type.rb b/app/graphql/types/design_management/design_at_version_type.rb
index 4240b8f3aae..0dc93072e4f 100644
--- a/app/graphql/types/design_management/design_at_version_type.rb
+++ b/app/graphql/types/design_management/design_at_version_type.rb
@@ -18,12 +18,12 @@ module Types
field :version,
Types::DesignManagement::VersionType,
null: false,
- description: 'The version this design-at-versions is pinned to.'
+ description: 'Version this design-at-versions is pinned to.'
field :design,
Types::DesignManagement::DesignType,
null: false,
- description: 'The underlying design.'
+ description: 'Underlying design.'
def cached_stateful_version(_parent)
version
diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb
index 7779c3f1bcb..75f1aaa8c60 100644
--- a/app/graphql/types/design_management/design_fields.rb
+++ b/app/graphql/types/design_management/design_fields.rb
@@ -7,12 +7,12 @@ module Types
field_class Types::BaseField
- field :id, GraphQL::Types::ID, description: 'The ID of this design.', null: false
- field :project, Types::ProjectType, null: false, description: 'The project the design belongs to.'
- field :issue, Types::IssueType, null: false, description: 'The issue the design belongs to.'
- field :filename, GraphQL::Types::String, null: false, description: 'The filename of the design.'
- field :full_path, GraphQL::Types::String, null: false, description: 'The full path to the design file.'
- field :image, GraphQL::Types::String, null: false, extras: [:parent], description: 'The URL of the full-sized image.'
+ field :id, GraphQL::Types::ID, description: 'ID of this design.', null: false
+ field :project, Types::ProjectType, null: false, description: 'Project the design belongs to.'
+ field :issue, Types::IssueType, null: false, description: 'Issue the design belongs to.'
+ field :filename, GraphQL::Types::String, null: false, description: 'Filename of the design.'
+ field :full_path, GraphQL::Types::String, null: false, description: 'Full path to the design file.'
+ field :image, GraphQL::Types::String, null: false, extras: [:parent], description: 'URL of the full-sized image.'
field :image_v432x230, GraphQL::Types::String, null: true, extras: [:parent],
description: 'The URL of the design resized to fit within the bounds of 432x230. ' \
'This will be `null` if the image has not been generated'
@@ -20,7 +20,7 @@ module Types
null: false,
calls_gitaly: true,
extras: [:parent],
- description: 'The diff refs for this design.'
+ description: 'Diff refs for this design.'
field :event, Types::DesignManagement::DesignVersionEventEnum,
null: false,
extras: [:parent],
@@ -29,7 +29,7 @@ module Types
GraphQL::Types::Int,
null: false,
method: :user_notes_count,
- description: 'The total count of user-created notes for this design.'
+ description: 'Total count of user-created notes for this design.'
def diff_refs(parent:)
version = cached_stateful_version(parent)
diff --git a/app/graphql/types/diff_paths_input_type.rb b/app/graphql/types/diff_paths_input_type.rb
index e1d3d58411c..cdcff1a7e34 100644
--- a/app/graphql/types/diff_paths_input_type.rb
+++ b/app/graphql/types/diff_paths_input_type.rb
@@ -3,8 +3,8 @@
module Types
class DiffPathsInputType < BaseInputObject
argument :old_path, GraphQL::Types::String, required: false,
- description: 'The path of the file on the start sha.'
+ description: 'Path of the file on the start SHA.'
argument :new_path, GraphQL::Types::String, required: false,
- description: 'The path of the file on the head sha.'
+ description: 'Path of the file on the HEAD SHA.'
end
end
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
index 267ca944198..aba83f559fa 100644
--- a/app/graphql/types/environment_type.rb
+++ b/app/graphql/types/environment_type.rb
@@ -19,7 +19,7 @@ module Types
description: 'State of the environment, for example: available/stopped.'
field :path, GraphQL::Types::String, null: false,
- description: 'The path to the environment.'
+ description: 'Path to the environment.'
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
description: 'Metrics dashboard schema for the environment.',
@@ -28,6 +28,6 @@ module Types
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
null: true,
- description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.'
+ description: 'Most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.'
end
end
diff --git a/app/graphql/types/eventable_type.rb b/app/graphql/types/eventable_type.rb
index eba2154e7fa..9a02079b7ab 100644
--- a/app/graphql/types/eventable_type.rb
+++ b/app/graphql/types/eventable_type.rb
@@ -4,6 +4,6 @@ module Types
module EventableType
include Types::BaseInterface
- field :events, Types::EventType.connection_type, null: true, description: 'A list of events associated with the object.'
+ field :events, Types::EventType.connection_type, null: true, description: 'List of events associated with the object.'
end
end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index fbf0084cd0e..8fe4ba557ea 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -33,12 +33,12 @@ module Types
type: GraphQL::Types::String,
null: true,
method: :project_creation_level_str,
- description: 'The permission level required to create projects in the group.'
+ description: 'Permission level required to create projects in the group.'
field :subgroup_creation_level,
type: GraphQL::Types::String,
null: true,
method: :subgroup_creation_level_str,
- description: 'The permission level required to create subgroups within the group.'
+ description: 'Permission level required to create subgroups within the group.'
field :require_two_factor_authentication,
type: GraphQL::Types::Boolean,
@@ -101,7 +101,7 @@ module Types
field :label,
Types::LabelType,
null: true,
- description: 'A label available on this group.' do
+ description: 'Label available on this group.' do
argument :title,
type: GraphQL::Types::String,
required: true,
@@ -128,6 +128,46 @@ module Types
description: 'Packages of the group.',
resolver: Resolvers::GroupPackagesResolver
+ field :dependency_proxy_setting,
+ Types::DependencyProxy::GroupSettingType,
+ null: true,
+ description: 'Dependency Proxy settings for the group.'
+
+ field :dependency_proxy_manifests,
+ Types::DependencyProxy::ManifestType.connection_type,
+ null: true,
+ description: 'Dependency Proxy manifests.'
+
+ field :dependency_proxy_blobs,
+ Types::DependencyProxy::BlobType.connection_type,
+ null: true,
+ description: 'Dependency Proxy blobs.'
+
+ field :dependency_proxy_image_count,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Number of dependency proxy images cached in the group.'
+
+ field :dependency_proxy_blob_count,
+ GraphQL::Types::Int,
+ null: false,
+ description: 'Number of dependency proxy blobs cached in the group.'
+
+ field :dependency_proxy_total_size,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Total size of the dependency proxy cached images.'
+
+ field :dependency_proxy_image_prefix,
+ GraphQL::Types::String,
+ null: false,
+ description: 'Prefix for pulling images when using the dependency proxy.'
+
+ field :dependency_proxy_image_ttl_policy,
+ Types::DependencyProxy::ImageTtlGroupPolicyType,
+ null: true,
+ description: 'Dependency proxy TTL policy for the group.'
+
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder
@@ -155,6 +195,19 @@ module Types
complexity: 5,
resolver: Resolvers::GroupsResolver
+ field :runners, Types::Ci::RunnerType.connection_type,
+ null: true,
+ resolver: Resolvers::Ci::GroupRunnersResolver,
+ description: "Find runners visible to the current user."
+
+ field :organizations, Types::CustomerRelations::OrganizationType.connection_type,
+ null: true,
+ description: "Find organizations of this group."
+
+ field :contacts, Types::CustomerRelations::ContactType.connection_type,
+ null: true,
+ description: "Find contacts of this group."
+
def avatar_url
object.avatar_url(only_path: false)
end
@@ -167,6 +220,20 @@ module Types
group.container_repositories.size
end
+ def dependency_proxy_image_count
+ group.dependency_proxy_manifests.count
+ end
+
+ def dependency_proxy_blob_count
+ group.dependency_proxy_blobs.count
+ end
+
+ def dependency_proxy_total_size
+ ActiveSupport::NumberHelper.number_to_human_size(
+ group.dependency_proxy_manifests.sum(:size) + group.dependency_proxy_blobs.sum(:size)
+ )
+ end
+
private
def group
diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb
index a2390ff01fe..f8825ff6c46 100644
--- a/app/graphql/types/issue_sort_enum.rb
+++ b/app/graphql/types/issue_sort_enum.rb
@@ -10,6 +10,8 @@ module Types
value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order.', value: :relative_position_asc
value 'SEVERITY_ASC', 'Severity from less critical to more critical.', value: :severity_asc
value 'SEVERITY_DESC', 'Severity from more critical to less critical.', value: :severity_desc
+ value 'TITLE_ASC', 'Title by ascending order.', value: :title_asc
+ value 'TITLE_DESC', 'Title by descending order.', value: :title_desc
value 'POPULARITY_ASC', 'Number of upvotes (awarded "thumbs up" emoji) by ascending order.', value: :popularity_asc
value 'POPULARITY_DESC', 'Number of upvotes (awarded "thumbs up" emoji) by descending order.', value: :popularity_desc
end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 42feb8a8076..c8db2b84ff2 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -53,6 +53,9 @@ module Types
description: 'Due date of the issue.'
field :confidential, GraphQL::Types::Boolean, null: false,
description: 'Indicates the issue is confidential.'
+ field :hidden, GraphQL::Types::Boolean, null: true, resolver_method: :hidden?,
+ description: 'Indicates the issue is hidden because the author has been banned. ' \
+ 'Will always return `null` if `ban_user_feature_flag` feature flag is disabled.'
field :discussion_locked, GraphQL::Types::Boolean, null: false,
description: 'Indicates discussion is locked on the issue.'
@@ -156,6 +159,10 @@ module Types
def create_note_email
object.creatable_note_email_address(context[:current_user])
end
+
+ def hidden?
+ object.hidden? if Feature.enabled?(:ban_user_feature_flag)
+ end
end
end
diff --git a/app/graphql/types/issues/negated_issue_filter_input_type.rb b/app/graphql/types/issues/negated_issue_filter_input_type.rb
index e5125c554a4..4f620a5b3d9 100644
--- a/app/graphql/types/issues/negated_issue_filter_input_type.rb
+++ b/app/graphql/types/issues/negated_issue_filter_input_type.rb
@@ -14,6 +14,9 @@ module Types
argument :milestone_title, [GraphQL::Types::String],
required: false,
description: 'Milestone not applied to this issue.'
+ argument :author_username, GraphQL::Types::String,
+ required: false,
+ description: "Username of a user who didn't author the issue."
argument :assignee_usernames, [GraphQL::Types::String],
required: false,
description: 'Usernames of users not assigned to the issue.'
@@ -23,6 +26,9 @@ module Types
argument :milestone_wildcard_id, ::Types::NegatedMilestoneWildcardIdEnum,
required: false,
description: 'Filter by negated milestone wildcard values.'
+ argument :my_reaction_emoji, GraphQL::Types::String,
+ required: false,
+ description: 'Filter by reaction emoji applied by the current user.'
end
end
end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 8e6b5421ede..004ac364487 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -64,7 +64,7 @@ module Types
description: 'Diff head SHA of the merge request.'
field :diff_stats, [Types::DiffStatsType], null: true, calls_gitaly: true,
description: 'Details about which files were changed in this merge request.' do
- argument :path, GraphQL::Types::String, required: false, description: 'A specific file-path.'
+ argument :path, GraphQL::Types::String, required: false, description: 'Specific file path.'
end
field :diff_stats_summary, Types::DiffStatsSummaryType, null: true, calls_gitaly: true,
@@ -129,14 +129,14 @@ module Types
description: 'Number of downvotes for the merge request.'
field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline,
- description: 'The pipeline running on the branch HEAD of the merge request.'
+ description: 'Pipeline running on the branch HEAD of the merge request.'
field :pipelines,
null: true,
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,
- description: 'The milestone of the merge request.'
+ description: 'Milestone of the merge request.'
field :assignees,
type: Types::MergeRequests::AssigneeType.connection_type,
null: true,
diff --git a/app/graphql/types/metadata/kas_type.rb b/app/graphql/types/metadata/kas_type.rb
index a947986fa60..54a8a6ec40d 100644
--- a/app/graphql/types/metadata/kas_type.rb
+++ b/app/graphql/types/metadata/kas_type.rb
@@ -12,7 +12,7 @@ module Types
field :version, GraphQL::Types::String, null: true,
description: 'KAS version.'
field :external_url, GraphQL::Types::String, null: true,
- description: 'The URL used by the Agents to communicate with KAS.'
+ description: 'URL used by the Agents to communicate with KAS.'
end
end
end
diff --git a/app/graphql/types/milestone_wildcard_id_enum.rb b/app/graphql/types/milestone_wildcard_id_enum.rb
index b5b339b1e5b..12e8e07fb05 100644
--- a/app/graphql/types/milestone_wildcard_id_enum.rb
+++ b/app/graphql/types/milestone_wildcard_id_enum.rb
@@ -6,8 +6,8 @@ module Types
description 'Milestone ID wildcard values'
value 'NONE', 'No milestone is assigned.'
- value 'ANY', 'A milestone is assigned.'
- value 'STARTED', 'An open, started milestone (start date <= today).'
- value 'UPCOMING', 'An open milestone due in the future (due date >= today).'
+ value 'ANY', 'Milestone is assigned.'
+ value 'STARTED', 'Milestone assigned is open and started (start date <= today).'
+ value 'UPCOMING', 'Milestone assigned is due closest in the future (due date > today).'
end
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 293d19d068a..ea50af1c554 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -33,7 +33,11 @@ module Types
mount_mutation Mutations::Branches::Create, calls_gitaly: true
mount_mutation Mutations::Commits::Create, calls_gitaly: true
mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji
+ mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji
+ mount_mutation Mutations::CustomerRelations::Organizations::Create
+ mount_mutation Mutations::CustomerRelations::Organizations::Update
mount_mutation Mutations::Discussions::ToggleResolve
+ mount_mutation Mutations::DependencyProxy::ImageTtlGroupPolicy::Update
mount_mutation Mutations::Environments::CanaryIngress::Update
mount_mutation Mutations::Issues::Create
mount_mutation Mutations::Issues::SetAssignees
@@ -103,9 +107,9 @@ module Types
mount_mutation Mutations::Ci::Job::Unschedule
mount_mutation Mutations::Ci::JobTokenScope::AddProject
mount_mutation Mutations::Ci::JobTokenScope::RemoveProject
- mount_mutation Mutations::Ci::Runner::Update, feature_flag: :runner_graphql_query
- mount_mutation Mutations::Ci::Runner::Delete, feature_flag: :runner_graphql_query
- mount_mutation Mutations::Ci::RunnersRegistrationToken::Reset, feature_flag: :runner_graphql_query
+ mount_mutation Mutations::Ci::Runner::Update
+ mount_mutation Mutations::Ci::Runner::Delete
+ mount_mutation Mutations::Ci::RunnersRegistrationToken::Reset
mount_mutation Mutations::Namespace::PackageSettings::Update
mount_mutation Mutations::Groups::Update
mount_mutation Mutations::UserCallouts::Create
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index 4cc543f477a..3c5994ac559 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -40,7 +40,7 @@ module Types
field :package_settings,
Types::Namespace::PackageSettingsType,
null: true,
- description: 'The package settings for the namespace.'
+ description: 'Package settings for the namespace.'
field :shared_runners_setting,
Types::Namespace::SharedRunnersSettingEnum,
diff --git a/app/graphql/types/negated_milestone_wildcard_id_enum.rb b/app/graphql/types/negated_milestone_wildcard_id_enum.rb
index ca27a6c7b6e..9e9f561677a 100644
--- a/app/graphql/types/negated_milestone_wildcard_id_enum.rb
+++ b/app/graphql/types/negated_milestone_wildcard_id_enum.rb
@@ -5,7 +5,7 @@ module Types
graphql_name 'NegatedMilestoneWildcardId'
description 'Negated Milestone ID wildcard values'
- value 'STARTED', 'An open, started milestone (start date <= today).'
- value 'UPCOMING', 'An open milestone due in the future (due date >= today).'
+ value 'STARTED', 'Milestone assigned is open and yet to be started (start date > today).'
+ value 'UPCOMING', 'Milestone assigned is open but due in the past (due date <= today).'
end
end
diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb
index fa33428114c..da6ea83401d 100644
--- a/app/graphql/types/notes/note_type.rb
+++ b/app/graphql/types/notes/note_type.rb
@@ -40,9 +40,9 @@ module Types
field :updated_at, Types::TimeType, null: false,
description: "Timestamp of the note's last activity."
field :discussion, Types::Notes::DiscussionType, null: true,
- description: 'The discussion this note is a part of.'
+ description: 'Discussion this note is a part of.'
field :position, Types::Notes::DiffPositionType, null: true,
- description: 'The position of this note on a diff.'
+ description: 'Position of this note on a diff.'
field :confidential, GraphQL::Types::Boolean, null: true,
description: 'Indicates if this note is confidential.',
method: :confidential?
diff --git a/app/graphql/types/notes/position_type_enum.rb b/app/graphql/types/notes/position_type_enum.rb
index 18934636670..157b0b4b884 100644
--- a/app/graphql/types/notes/position_type_enum.rb
+++ b/app/graphql/types/notes/position_type_enum.rb
@@ -6,7 +6,7 @@ module Types
graphql_name 'DiffPositionType'
description 'Type of file the position refers to'
- value 'text', description: "A text file."
+ value 'text', description: "Text file."
value 'image', description: "An image."
end
end
diff --git a/app/graphql/types/packages/composer/json_type.rb b/app/graphql/types/packages/composer/json_type.rb
index d2bd62ca74d..6c121043301 100644
--- a/app/graphql/types/packages/composer/json_type.rb
+++ b/app/graphql/types/packages/composer/json_type.rb
@@ -8,10 +8,10 @@ module Types
graphql_name 'PackageComposerJsonType'
description 'Represents a composer JSON file'
- field :name, GraphQL::Types::String, null: true, description: 'The name set in the Composer JSON file.'
- field :type, GraphQL::Types::String, null: true, description: 'The type set in the Composer JSON file.'
- field :license, GraphQL::Types::String, null: true, description: 'The license set in the Composer JSON file.'
- field :version, GraphQL::Types::String, null: true, description: 'The version set in the Composer JSON file.'
+ field :name, GraphQL::Types::String, null: true, description: 'Name set in the Composer JSON file.'
+ field :type, GraphQL::Types::String, null: true, description: 'Type set in the Composer JSON file.'
+ field :license, GraphQL::Types::String, null: true, description: 'License set in the Composer JSON file.'
+ field :version, GraphQL::Types::String, null: true, description: 'Version set in the Composer JSON file.'
end
end
end
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
index f52b1f02519..59a4885e87e 100644
--- a/app/graphql/types/packages/package_details_type.rb
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -8,7 +8,7 @@ module Types
authorize :read_package
field :versions, ::Types::Packages::PackageType.connection_type, null: true,
- description: 'The other versions of the package.'
+ description: 'Other versions of the package.'
field :package_files, Types::Packages::PackageFileType.connection_type, null: true, description: 'Package files.'
diff --git a/app/graphql/types/packages/package_file_type.rb b/app/graphql/types/packages/package_file_type.rb
index f77c40de8d8..8cc0f9b984a 100644
--- a/app/graphql/types/packages/package_file_type.rb
+++ b/app/graphql/types/packages/package_file_type.rb
@@ -8,8 +8,8 @@ module Types
authorize :read_package
field :id, ::Types::GlobalIDType[::Packages::PackageFile], null: false, description: 'ID of the file.'
- field :created_at, Types::TimeType, null: false, description: 'The created date.'
- field :updated_at, Types::TimeType, null: false, description: 'The updated date.'
+ field :created_at, Types::TimeType, null: false, description: 'Created date.'
+ field :updated_at, Types::TimeType, null: false, description: 'Updated date.'
field :size, GraphQL::Types::String, null: false, description: 'Size of the package file.'
field :file_name, GraphQL::Types::String, null: false, description: 'Name of the package file.'
field :download_path, GraphQL::Types::String, null: false, description: 'Download path of the package file.'
diff --git a/app/graphql/types/packages/package_tag_type.rb b/app/graphql/types/packages/package_tag_type.rb
index 450f3fc8e9c..f1f96c42e27 100644
--- a/app/graphql/types/packages/package_tag_type.rb
+++ b/app/graphql/types/packages/package_tag_type.rb
@@ -7,10 +7,10 @@ module Types
description 'Represents a package tag'
authorize :read_package
- field :id, GraphQL::Types::ID, null: false, description: 'The ID of the tag.'
- field :name, GraphQL::Types::String, null: false, description: 'The name of the tag.'
- field :created_at, Types::TimeType, null: false, description: 'The created date.'
- field :updated_at, Types::TimeType, null: false, description: 'The updated date.'
+ field :id, GraphQL::Types::ID, null: false, description: 'ID of the tag.'
+ field :name, GraphQL::Types::String, null: false, description: 'Name of the tag.'
+ field :created_at, Types::TimeType, null: false, description: 'Created date.'
+ field :updated_at, Types::TimeType, null: false, description: 'Updated date.'
end
end
end
diff --git a/app/graphql/types/packages/package_type.rb b/app/graphql/types/packages/package_type.rb
index b8654ebd2c6..f3fa79cc08c 100644
--- a/app/graphql/types/packages/package_type.rb
+++ b/app/graphql/types/packages/package_type.rb
@@ -23,7 +23,7 @@ module Types
field :metadata, Types::Packages::MetadataType, null: true,
description: 'Package metadata.'
field :versions, ::Types::Packages::PackageType.connection_type, null: true,
- description: 'The other versions of the package.',
+ description: 'Other versions of the package.',
deprecated: { reason: 'This field is now only returned in the PackageDetailsType', milestone: '13.11' }
field :status, Types::Packages::PackageStatusEnum, null: false, description: 'Package status.'
diff --git a/app/graphql/types/permission_types/custom_emoji.rb b/app/graphql/types/permission_types/custom_emoji.rb
index 0b2e7da44f5..61ff85f665d 100644
--- a/app/graphql/types/permission_types/custom_emoji.rb
+++ b/app/graphql/types/permission_types/custom_emoji.rb
@@ -5,7 +5,7 @@ module Types
class CustomEmoji < BasePermissionType
graphql_name 'CustomEmojiPermissions'
- abilities :create_custom_emoji, :read_custom_emoji
+ abilities :create_custom_emoji, :read_custom_emoji, :delete_custom_emoji
end
end
end
diff --git a/app/graphql/types/permission_types/group.rb b/app/graphql/types/permission_types/group.rb
index 29833993ce6..6a1031e2532 100644
--- a/app/graphql/types/permission_types/group.rb
+++ b/app/graphql/types/permission_types/group.rb
@@ -5,7 +5,7 @@ module Types
class Group < BasePermissionType
graphql_name 'GroupPermissions'
- abilities :read_group
+ abilities :read_group, :create_projects
end
end
end
diff --git a/app/graphql/types/permission_types/group_enum.rb b/app/graphql/types/permission_types/group_enum.rb
new file mode 100644
index 00000000000..cc4f5e9f1f0
--- /dev/null
+++ b/app/graphql/types/permission_types/group_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ module PermissionTypes
+ class GroupEnum < BaseEnum
+ graphql_name 'GroupPermission'
+ description 'User permission on groups'
+
+ value 'CREATE_PROJECTS', value: :create_projects, description: 'Groups where the user can create projects.'
+ end
+ end
+end
diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb
index ee8476f50de..60a3d5ce06b 100644
--- a/app/graphql/types/project_statistics_type.rb
+++ b/app/graphql/types/project_statistics_type.rb
@@ -23,6 +23,8 @@ module Types
description: 'Wiki size of the project in bytes.'
field :snippets_size, GraphQL::FLOAT_TYPE, null: true,
description: 'Snippets size of the project in bytes.'
+ field :pipeline_artifacts_size, GraphQL::FLOAT_TYPE, null: true,
+ description: 'CI Pipeline artifacts size in bytes.'
field :uploads_size, GraphQL::FLOAT_TYPE, null: true,
description: 'Uploads size of the project in bytes.'
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index af1f1c54ec2..aef46a05a2f 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -118,7 +118,7 @@ module Types
field :autoclose_referenced_issues, GraphQL::Types::Boolean, null: true,
description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically.'
field :suggestion_commit_message, GraphQL::Types::String, null: true,
- description: 'The commit message used to apply merge request suggestions.'
+ description: 'Commit message used to apply merge request suggestions.'
field :squash_read_only, GraphQL::Types::Boolean, null: false, method: :squash_readonly?,
description: 'Indicates if `squashReadOnly` is enabled.'
@@ -310,7 +310,7 @@ module Types
field :container_expiration_policy,
Types::ContainerExpirationPolicyType,
null: true,
- description: 'The container expiration policy of the project.'
+ description: 'Container expiration policy of the project.'
field :container_repositories,
Types::ContainerRepositoryType.connection_type,
@@ -324,7 +324,7 @@ module Types
field :label,
Types::LabelType,
null: true,
- description: 'A label available on this project.' do
+ description: 'Label available on this project.' do
argument :title, GraphQL::Types::String,
required: true,
description: 'Title of the label.'
@@ -406,6 +406,10 @@ module Types
object.topic_list
end
+ def topics
+ object.topic_list
+ end
+
private
def project
diff --git a/app/graphql/types/prometheus_alert_type.rb b/app/graphql/types/prometheus_alert_type.rb
index 8327848032a..789f1d6eb5f 100644
--- a/app/graphql/types/prometheus_alert_type.rb
+++ b/app/graphql/types/prometheus_alert_type.rb
@@ -15,6 +15,6 @@ module Types
field :humanized_text,
GraphQL::Types::String,
null: false,
- description: 'The human-readable text of the alert condition.'
+ description: 'Human-readable text of the alert condition.'
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 7e9cd615719..e02191fbf3e 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -62,7 +62,7 @@ module Types
argument :id,
type: ::Types::GlobalIDType[::ContainerRepository],
required: true,
- description: 'The global ID of the container repository.'
+ description: 'Global ID of the container repository.'
end
field :package,
@@ -84,13 +84,13 @@ module Types
field :issue, Types::IssueType,
null: true,
description: 'Find an issue.' do
- argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'The global ID of the issue.'
+ argument :id, ::Types::GlobalIDType[::Issue], required: true, description: 'Global ID of the issue.'
end
field :merge_request, Types::MergeRequestType,
null: true,
description: 'Find a merge request.' do
- argument :id, ::Types::GlobalIDType[::MergeRequest], required: true, description: 'The global ID of the merge request.'
+ argument :id, ::Types::GlobalIDType[::MergeRequest], required: true, description: 'Global ID of the merge request.'
end
field :instance_statistics_measurements,
@@ -120,14 +120,12 @@ module Types
null: true,
resolver: Resolvers::Ci::RunnerResolver,
extras: [:lookahead],
- description: "Find a runner.",
- feature_flag: :runner_graphql_query
+ description: "Find a runner."
field :runners, Types::Ci::RunnerType.connection_type,
null: true,
resolver: Resolvers::Ci::RunnersResolver,
- description: "Find runners visible to the current user.",
- feature_flag: :runner_graphql_query
+ description: "Find runners visible to the current user."
field :ci_config, resolver: Resolvers::Ci::ConfigResolver, complexity: 126 # AUTHENTICATED_MAX_COMPLEXITY / 2 + 1
diff --git a/app/graphql/types/range_input_type.rb b/app/graphql/types/range_input_type.rb
index e31b8ecd811..9580b37d6c0 100644
--- a/app/graphql/types/range_input_type.rb
+++ b/app/graphql/types/range_input_type.rb
@@ -8,11 +8,11 @@ module Types
@subtypes[[type, closed]] ||= Class.new(self) do
argument :start, type,
required: closed,
- description: 'The start of the range.'
+ description: 'Start of the range.'
argument :end, type,
required: closed,
- description: 'The end of the range.'
+ description: 'End of the range.'
end
end
diff --git a/app/graphql/types/release_asset_link_shared_input_arguments.rb b/app/graphql/types/release_asset_link_shared_input_arguments.rb
index 37a6cdd55c9..f622a11deea 100644
--- a/app/graphql/types/release_asset_link_shared_input_arguments.rb
+++ b/app/graphql/types/release_asset_link_shared_input_arguments.rb
@@ -19,7 +19,7 @@ module Types
argument :link_type, Types::ReleaseAssetLinkTypeEnum,
required: false, default_value: 'other',
- description: 'The type of the asset link.'
+ description: 'Type of the asset link.'
end
end
end
diff --git a/app/graphql/types/release_assets_input_type.rb b/app/graphql/types/release_assets_input_type.rb
index 2c8d3de3495..0e591dd3742 100644
--- a/app/graphql/types/release_assets_input_type.rb
+++ b/app/graphql/types/release_assets_input_type.rb
@@ -7,6 +7,6 @@ module Types
argument :links, [Types::ReleaseAssetLinkInputType],
required: false,
- description: 'A list of asset links to associate to the release.'
+ description: 'List of asset links to associate to the release.'
end
end
diff --git a/app/graphql/types/release_type.rb b/app/graphql/types/release_type.rb
index 5e8f00b2b0a..6dda93c7329 100644
--- a/app/graphql/types/release_type.rb
+++ b/app/graphql/types/release_type.rb
@@ -49,7 +49,7 @@ module Types
field :commit, Types::CommitType, null: true,
complexity: 10, calls_gitaly: true,
- description: 'The commit associated with the release.'
+ description: 'Commit associated with the release.'
def commit
return if release.sha.nil?
diff --git a/app/graphql/types/repository/blob_type.rb b/app/graphql/types/repository/blob_type.rb
index b6a1a91fd7a..ef7f535212f 100644
--- a/app/graphql/types/repository/blob_type.rb
+++ b/app/graphql/types/repository/blob_type.rb
@@ -48,10 +48,10 @@ module Types
description: 'Size (in bytes) of the blob, or the blob target if stored externally.'
field :raw_blob, GraphQL::Types::String, null: true, method: :data,
- description: 'The raw content of the blob.'
+ description: 'Raw content of the blob.'
field :raw_text_blob, GraphQL::Types::String, null: true, method: :text_only_data,
- description: 'The raw content of the blob, if the blob is text data.'
+ description: 'Raw content of the blob, if the blob is text data.'
field :stored_externally, GraphQL::Types::Boolean, null: true, method: :stored_externally?,
description: "Whether the blob's content is stored externally (for instance, in LFS)."
@@ -69,7 +69,7 @@ module Types
description: 'Web path to replace the blob content.'
field :file_type, GraphQL::Types::String, null: true,
- description: 'The expected format of the blob based on the extension.'
+ description: 'Expected format of the blob based on the extension.'
field :simple_viewer, type: Types::BlobViewerType,
description: 'Blob content simple viewer.',
diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb
index 102639f19d9..47ca195cc4b 100644
--- a/app/graphql/types/root_storage_statistics_type.rb
+++ b/app/graphql/types/root_storage_statistics_type.rb
@@ -6,14 +6,14 @@ module Types
authorize :read_statistics
- field :storage_size, GraphQL::FLOAT_TYPE, null: false, description: 'The total storage in bytes.'
- field :repository_size, GraphQL::FLOAT_TYPE, null: false, description: 'The Git repository size in bytes.'
- field :lfs_objects_size, GraphQL::FLOAT_TYPE, null: false, description: 'The LFS objects size in bytes.'
- field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'The CI artifacts size in bytes.'
- field :packages_size, GraphQL::FLOAT_TYPE, null: false, description: 'The packages size in bytes.'
- field :wiki_size, GraphQL::FLOAT_TYPE, null: false, description: 'The wiki size in bytes.'
- field :snippets_size, GraphQL::FLOAT_TYPE, null: false, description: 'The snippets size in bytes.'
- field :pipeline_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'The CI pipeline artifacts size in bytes.'
- field :uploads_size, GraphQL::FLOAT_TYPE, null: false, description: 'The uploads size in bytes.'
+ field :storage_size, GraphQL::FLOAT_TYPE, null: false, description: 'Total storage in bytes.'
+ field :repository_size, GraphQL::FLOAT_TYPE, null: false, description: 'Git repository size in bytes.'
+ field :lfs_objects_size, GraphQL::FLOAT_TYPE, null: false, description: 'LFS objects size in bytes.'
+ field :build_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'CI artifacts size in bytes.'
+ field :packages_size, GraphQL::FLOAT_TYPE, null: false, description: 'Packages size in bytes.'
+ field :wiki_size, GraphQL::FLOAT_TYPE, null: false, description: 'Wiki size in bytes.'
+ field :snippets_size, GraphQL::FLOAT_TYPE, null: false, description: 'Snippets size in bytes.'
+ field :pipeline_artifacts_size, GraphQL::FLOAT_TYPE, null: false, description: 'CI pipeline artifacts size in bytes.'
+ field :uploads_size, GraphQL::FLOAT_TYPE, null: false, description: 'Uploads size in bytes.'
end
end
diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb
index c345aea08bd..7b96cc34941 100644
--- a/app/graphql/types/snippet_type.rb
+++ b/app/graphql/types/snippet_type.rb
@@ -22,7 +22,7 @@ module Types
null: false
field :project, Types::ProjectType,
- description: 'The project the snippet is associated with.',
+ description: 'Project the snippet is associated with.',
null: true,
authorize: :read_project
@@ -30,7 +30,7 @@ module Types
# when the admin setting restricted visibility
# level is set to public
field :author, Types::UserType,
- description: 'The owner of the snippet.',
+ description: 'Owner of the snippet.',
null: true
field :file_name, GraphQL::Types::String,
diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb
index d5da271d936..2b9b76a6194 100644
--- a/app/graphql/types/snippets/blob_type.rb
+++ b/app/graphql/types/snippets/blob_type.rb
@@ -17,7 +17,7 @@ module Types
null: true
field :raw_plain_data, GraphQL::Types::String,
- description: 'The raw content of the blob, if the blob is text data.',
+ description: 'Raw content of the blob, if the blob is text data.',
null: true
field :raw_path, GraphQL::Types::String,
diff --git a/app/graphql/types/snippets/visibility_scopes_enum.rb b/app/graphql/types/snippets/visibility_scopes_enum.rb
index ddcc005eaf2..b2c1d5cf06b 100644
--- a/app/graphql/types/snippets/visibility_scopes_enum.rb
+++ b/app/graphql/types/snippets/visibility_scopes_enum.rb
@@ -3,9 +3,9 @@
module Types
module Snippets
class VisibilityScopesEnum < BaseEnum
- value 'private', description: 'The snippet is visible only to the snippet creator.', value: 'are_private'
- value 'internal', description: 'The snippet is visible for any logged in user except external users.', value: 'are_internal'
- value 'public', description: 'The snippet can be accessed without any authentication.', value: 'are_public'
+ value 'private', description: 'Snippet is visible only to the snippet creator.', value: 'are_private'
+ value 'internal', description: 'Snippet is visible for any logged in user except external users.', value: 'are_internal'
+ value 'public', description: 'Snippet can be accessed without any authentication.', value: 'are_public'
end
end
end
diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb
index cbd5aeaeef9..bce34a85f85 100644
--- a/app/graphql/types/terraform/state_type.rb
+++ b/app/graphql/types/terraform/state_type.rb
@@ -19,7 +19,7 @@ module Types
field :locked_by_user, Types::UserType,
null: true,
- description: 'The user currently holding a lock on the Terraform state.'
+ description: 'User currently holding a lock on the Terraform state.'
field :locked_at, Types::TimeType,
null: true,
@@ -28,7 +28,7 @@ module Types
field :latest_version, Types::Terraform::StateVersionType,
complexity: 3,
null: true,
- description: 'The latest version of the Terraform state.'
+ description: 'Latest version of the Terraform state.'
field :created_at, Types::TimeType,
null: false,
diff --git a/app/graphql/types/terraform/state_version_type.rb b/app/graphql/types/terraform/state_version_type.rb
index 545b3c0044d..bf1af4565bc 100644
--- a/app/graphql/types/terraform/state_version_type.rb
+++ b/app/graphql/types/terraform/state_version_type.rb
@@ -15,7 +15,7 @@ module Types
field :created_by_user, Types::UserType,
null: true,
- description: 'The user that created this version.'
+ description: 'User that created this version.'
field :download_path, GraphQL::Types::String,
null: true,
@@ -23,7 +23,8 @@ module Types
field :job, Types::Ci::JobType,
null: true,
- description: 'The job that created this version.'
+ description: 'Job that created this version.',
+ authorize: :read_commit_status
field :serial, GraphQL::Types::Int,
null: true,
diff --git a/app/graphql/types/timelog_type.rb b/app/graphql/types/timelog_type.rb
index 206aabbada3..d348fa698fa 100644
--- a/app/graphql/types/timelog_type.rb
+++ b/app/graphql/types/timelog_type.rb
@@ -14,31 +14,31 @@ module Types
field :time_spent,
GraphQL::Types::Int,
null: false,
- description: 'The time spent displayed in seconds.'
+ description: 'Time spent displayed in seconds.'
field :user,
Types::UserType,
null: false,
- description: 'The user that logged the time.'
+ description: 'User that logged the time.'
field :issue,
Types::IssueType,
null: true,
- description: 'The issue that logged time was added to.'
+ description: 'Issue that logged time was added to.'
field :merge_request,
Types::MergeRequestType,
null: true,
- description: 'The merge request that logged time was added to.'
+ description: 'Merge request that logged time was added to.'
field :note,
Types::Notes::NoteType,
null: true,
- description: 'The note where the quick action to add the logged time was executed.'
+ description: 'Note where the quick action was executed to add the logged time.'
field :summary, GraphQL::Types::String,
null: true,
- description: 'The summary of how the time was spent.'
+ description: 'Summary of how the time was spent.'
def user
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.user_id).find
diff --git a/app/graphql/types/todo_state_enum.rb b/app/graphql/types/todo_state_enum.rb
index 604e2a62f70..e6fc8867a80 100644
--- a/app/graphql/types/todo_state_enum.rb
+++ b/app/graphql/types/todo_state_enum.rb
@@ -2,7 +2,7 @@
module Types
class TodoStateEnum < BaseEnum
- value 'pending', description: "The state of the todo is pending."
- value 'done', description: "The state of the todo is done."
+ value 'pending', description: "State of the todo is pending."
+ value 'done', description: "State of the todo is done."
end
end
diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb
index ce61bc8a926..dbf7b42ffcc 100644
--- a/app/graphql/types/todo_target_enum.rb
+++ b/app/graphql/types/todo_target_enum.rb
@@ -2,11 +2,11 @@
module Types
class TodoTargetEnum < BaseEnum
- value 'COMMIT', value: 'Commit', description: 'A Commit.'
- value 'ISSUE', value: 'Issue', description: 'An Issue.'
- value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest.'
- value 'DESIGN', value: 'DesignManagement::Design', description: 'A Design.'
- value 'ALERT', value: 'AlertManagement::Alert', description: 'An Alert.'
+ value 'COMMIT', value: 'Commit', description: 'Commit.'
+ value 'ISSUE', value: 'Issue', description: 'Issue.'
+ value 'MERGEREQUEST', value: 'MergeRequest', description: 'Merge request.'
+ value 'DESIGN', value: 'DesignManagement::Design', description: 'Design.'
+ value 'ALERT', value: 'AlertManagement::Alert', description: 'Alert.'
end
end
diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb
index 24c110ce09b..34ba2c75b5f 100644
--- a/app/graphql/types/todo_type.rb
+++ b/app/graphql/types/todo_type.rb
@@ -14,7 +14,7 @@ module Types
null: false
field :project, Types::ProjectType,
- description: 'The project this to-do item is associated with.',
+ description: 'Project this to-do item is associated with.',
null: true,
authorize: :read_project
@@ -24,7 +24,7 @@ module Types
authorize: :read_group
field :author, Types::UserType,
- description: 'The author of this to-do item.',
+ description: 'Author of this to-do item.',
null: false
field :action, Types::TodoActionEnum,
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index 71c6b7f3019..8c67275eb73 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -59,6 +59,10 @@ module Types
type: Types::GroupMemberType.connection_type,
null: true,
description: 'Group memberships of the user.'
+ field :groups,
+ resolver: Resolvers::Users::GroupsResolver,
+ description: 'Groups where the user has access. Will always return `null` if ' \
+ '`paginatable_namespace_drop_down_for_project_creation` feature flag is disabled.'
field :group_count,
resolver: Resolvers::Users::GroupCountResolver,
description: 'Group count for the user.'
@@ -69,7 +73,7 @@ module Types
field :location,
type: ::GraphQL::Types::String,
null: true,
- description: 'The location of the user.'
+ description: 'Location of the user.'
field :project_memberships,
type: Types::ProjectMemberType.connection_type,
null: true,
@@ -82,9 +86,7 @@ module Types
null: true,
description: 'Personal namespace of the user.'
- field :todos, resolver: Resolvers::TodoResolver, description: 'To-do items of the user.' do
- extension(::Gitlab::Graphql::TodosProjectPermissionPreloader::FieldExtension)
- end
+ field :todos, resolver: Resolvers::TodoResolver, description: 'To-do items of the user.'
# Merge request field: MRs can be authored, assigned, or assigned-for-review:
field :authored_merge_requests,
diff --git a/app/graphql/types/user_merge_request_interaction_type.rb b/app/graphql/types/user_merge_request_interaction_type.rb
index ff6e83efbb2..06e318be5c6 100644
--- a/app/graphql/types/user_merge_request_interaction_type.rb
+++ b/app/graphql/types/user_merge_request_interaction_type.rb
@@ -28,7 +28,7 @@ module Types
field :review_state,
::Types::MergeRequestReviewStateEnum,
null: true,
- description: 'The state of the review by this user.'
+ description: 'State of the review by this user.'
field :reviewed,
type: ::GraphQL::Types::Boolean,
diff --git a/app/graphql/types/user_state_enum.rb b/app/graphql/types/user_state_enum.rb
index 5adec17672e..de15fc19682 100644
--- a/app/graphql/types/user_state_enum.rb
+++ b/app/graphql/types/user_state_enum.rb
@@ -5,8 +5,8 @@ module Types
graphql_name 'UserState'
description 'Possible states of a user'
- value 'active', 'The user is active and is able to use the system.', value: 'active'
- value 'blocked', 'The user has been blocked and is prevented from using the system.', value: 'blocked'
- value 'deactivated', 'The user is no longer active and is unable to use the system.', value: 'deactivated'
+ value 'active', 'User is active and is able to use the system.', value: 'active'
+ value 'blocked', 'User has been blocked and is prevented from using the system.', value: 'blocked'
+ value 'deactivated', 'User is no longer active and is unable to use the system.', value: 'deactivated'
end
end
diff --git a/app/helpers/analytics/cycle_analytics_helper.rb b/app/helpers/analytics/cycle_analytics_helper.rb
index c43ac545bf8..35a5d4f469d 100644
--- a/app/helpers/analytics/cycle_analytics_helper.rb
+++ b/app/helpers/analytics/cycle_analytics_helper.rb
@@ -7,5 +7,23 @@ module Analytics
Analytics::CycleAnalytics::StagePresenter.new(stage_params)
end
end
+
+ def cycle_analytics_initial_data(project, group = nil)
+ base_data = { project_id: project.id, group_path: project.group&.path, request_path: project_cycle_analytics_path(project), full_path: project.full_path }
+ svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") }
+ api_paths = group.present? ? cycle_analytics_group_api_paths(group) : cycle_analytics_project_api_paths(project)
+
+ base_data.merge(svgs, api_paths)
+ end
+
+ private
+
+ def cycle_analytics_group_api_paths(group)
+ { milestones_path: group_milestones_path(group, format: :json), labels_path: group_labels_path(group, format: :json), group_path: group_path(group), group_id: group&.id }
+ end
+
+ def cycle_analytics_project_api_paths(project)
+ { milestones_path: project_milestones_path(project, format: :json), labels_path: project_labels_path(project, format: :json), group_path: project.parent&.path, group_id: project.parent&.id }
+ end
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 2447a731167..cf15433f2e5 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -176,6 +176,16 @@ module ApplicationSettingsHelper
"and the value is encrypted at rest.")
end
+ def sidekiq_job_limiter_mode_help_text
+ _("How the job limiter handles jobs exceeding the thresholds specified below. "\
+ "The 'track' mode only logs the jobs. The 'compress' mode compresses the jobs and "\
+ "raises an exception if the compressed size exceeds the limit.")
+ end
+
+ def sidekiq_job_limiter_modes_for_select
+ ApplicationSetting.sidekiq_job_limiter_modes.keys.map { |mode| [mode.humanize, mode] }
+ end
+
def visible_attributes
[
:abuse_notification_email,
@@ -263,6 +273,8 @@ module ApplicationSettingsHelper
:max_attachment_size,
:max_import_size,
:max_pages_size,
+ :max_yaml_size_bytes,
+ :max_yaml_depth,
:metrics_method_call_threshold,
:minimum_password_length,
:mirror_available,
@@ -309,18 +321,30 @@ module ApplicationSettingsHelper
:throttle_authenticated_api_enabled,
:throttle_authenticated_api_period_in_seconds,
:throttle_authenticated_api_requests_per_period,
+ :throttle_authenticated_git_lfs_enabled,
+ :throttle_authenticated_git_lfs_period_in_seconds,
+ :throttle_authenticated_git_lfs_requests_per_period,
:throttle_authenticated_web_enabled,
:throttle_authenticated_web_period_in_seconds,
:throttle_authenticated_web_requests_per_period,
:throttle_authenticated_packages_api_enabled,
:throttle_authenticated_packages_api_period_in_seconds,
:throttle_authenticated_packages_api_requests_per_period,
+ :throttle_authenticated_files_api_enabled,
+ :throttle_authenticated_files_api_period_in_seconds,
+ :throttle_authenticated_files_api_requests_per_period,
+ :throttle_unauthenticated_api_enabled,
+ :throttle_unauthenticated_api_period_in_seconds,
+ :throttle_unauthenticated_api_requests_per_period,
:throttle_unauthenticated_enabled,
:throttle_unauthenticated_period_in_seconds,
:throttle_unauthenticated_requests_per_period,
:throttle_unauthenticated_packages_api_enabled,
:throttle_unauthenticated_packages_api_period_in_seconds,
:throttle_unauthenticated_packages_api_requests_per_period,
+ :throttle_unauthenticated_files_api_enabled,
+ :throttle_unauthenticated_files_api_period_in_seconds,
+ :throttle_unauthenticated_files_api_requests_per_period,
:throttle_protected_paths_enabled,
:throttle_protected_paths_period_in_seconds,
:throttle_protected_paths_requests_per_period,
@@ -372,7 +396,11 @@ module ApplicationSettingsHelper
:container_registry_expiration_policies_worker_capacity,
:container_registry_cleanup_tags_service_max_list_size,
:keep_latest_artifact,
- :whats_new_variant
+ :whats_new_variant,
+ :user_deactivation_emails_enabled,
+ :sidekiq_job_limiter_mode,
+ :sidekiq_job_limiter_compression_threshold_bytes,
+ :sidekiq_job_limiter_limit_bytes
].tap do |settings|
settings << :deactivate_dormant_users unless Gitlab.com?
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index c1a33794b50..f0e8ff7778e 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -183,6 +183,10 @@ module BlobHelper
blob_raw_url(**kwargs, only_path: true)
end
+ def parent_dir_raw_path
+ blob_raw_path.rpartition("/").first + "/"
+ end
+
# SVGs can contain malicious JavaScript; only include whitelisted
# elements and attributes. Note that this whitelist is by no means complete
# and may omit some elements.
diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb
index 4dfe136c206..9bbc326a750 100644
--- a/app/helpers/ci/pipeline_editor_helper.rb
+++ b/app/helpers/ci/pipeline_editor_helper.rb
@@ -16,7 +16,6 @@ module Ci
"ci-config-path": project.ci_config_path_or_default,
"ci-examples-help-page-path" => help_page_path('ci/examples/index'),
"ci-help-page-path" => help_page_path('ci/index'),
- "commit-sha" => commit_sha,
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'),
"initial-branch-name" => initial_branch,
diff --git a/app/helpers/ci/runners_helper.rb b/app/helpers/ci/runners_helper.rb
index 550fa4de2c5..c9231a4eff3 100644
--- a/app/helpers/ci/runners_helper.rb
+++ b/app/helpers/ci/runners_helper.rb
@@ -65,6 +65,15 @@ module Ci
}
end
+ def group_runners_data_attributes(group)
+ {
+ registration_token: group.runners_token,
+ group_id: group.id,
+ group_full_path: group.full_path,
+ runner_install_help_page: 'https://docs.gitlab.com/runner/install/'
+ }
+ end
+
def toggle_shared_runners_settings_data(project)
{
is_enabled: "#{project.shared_runners_enabled?}",
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index 40d7eab584c..ca5fe38576e 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -183,9 +183,9 @@ module DiffHelper
def diff_file_changed_icon_color(diff_file)
if diff_file.deleted_file?
- "cred"
+ "danger"
elsif diff_file.new_file?
- "cgreen"
+ "success"
end
end
@@ -248,6 +248,23 @@ module DiffHelper
toggle_whitespace_link(url, options)
end
+ def diff_files_data(diff_files)
+ diffs_map = diff_files.map do |f|
+ {
+ href: "##{hexdigest(f.file_path)}",
+ title: f.new_path,
+ name: f.file_path,
+ path: diff_file_path_text(f),
+ icon: diff_file_changed_icon(f),
+ iconColor: "#{diff_file_changed_icon_color(f)}",
+ added: f.added_lines,
+ removed: f.removed_lines
+ }
+ end
+
+ diffs_map.to_json
+ end
+
def hide_whitespace?
params[:w] == '1'
end
diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb
index 3f23f73eed7..f57bb600527 100644
--- a/app/helpers/environment_helper.rb
+++ b/app/helpers/environment_helper.rb
@@ -73,7 +73,6 @@ module EnvironmentHelper
external_url: environment.external_url,
can_update_environment: can?(current_user, :update_environment, environment),
can_destroy_environment: can_destroy_environment?(environment),
- can_read_environment: can?(current_user, :read_environment, environment),
can_stop_environment: can?(current_user, :stop_environment, environment),
can_admin_environment: can?(current_user, :admin_environment, project),
environment_metrics_path: environment_metrics_path(environment),
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 0f835e6881e..1be395437ea 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -16,6 +16,7 @@ module GitlabRoutingHelper
include ::Routing::SnippetsHelper
include ::Routing::WikiHelper
include ::Routing::GraphqlHelper
+ include ::Routing::PseudonymizationHelper
included do
Gitlab::Routing.includes_helpers(self)
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 0e4aeaae20d..a24776eb2e4 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -1,14 +1,6 @@
# frozen_string_literal: true
module GroupsHelper
- def group_sidebar_links
- @group_sidebar_links ||= get_group_sidebar_links
- end
-
- def group_sidebar_link?(link)
- group_sidebar_links.include?(link)
- end
-
def can_change_group_visibility_level?(group)
can?(current_user, :change_visibility_level, group)
end
@@ -33,29 +25,6 @@ module GroupsHelper
Ability.allowed?(current_user, :admin_group_member, group)
end
- def group_issues_count(state:)
- IssuesFinder
- .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true)
- .execute
- .count
- end
-
- def group_merge_requests_count(state:)
- MergeRequestsFinder
- .new(current_user, group_id: @group.id, state: state, non_archived: true, include_subgroups: true)
- .execute
- .count
- end
-
- def group_dependency_proxy_image_prefix(group)
- # The namespace path can include uppercase letters, which
- # Docker doesn't allow. The proxy expects it to be downcased.
- url = "#{group_url(group).downcase}#{DependencyProxy::URL_SUFFIX}"
-
- # Docker images do not include the protocol
- url.partition('//').last
- end
-
def group_icon_url(group, options = {})
if group.is_a?(String)
group = Group.find_by_full_path(group)
@@ -153,12 +122,6 @@ module GroupsHelper
groups.to_json
end
- def show_invite_banner?(group)
- can?(current_user, :admin_group, group) &&
- !just_created? &&
- !multiple_members?(group)
- end
-
def render_setting_to_allow_project_access_token_creation?(group)
group.root? && current_user.can?(:admin_setting_to_allow_project_access_token_creation, group)
end
@@ -173,44 +136,6 @@ module GroupsHelper
private
- def just_created?
- flash[:notice] =~ /successfully created/
- end
-
- def multiple_members?(group)
- group.member_count > 1 || group.members_with_parents.count > 1
- end
-
- def get_group_sidebar_links
- links = [:overview, :group_members]
-
- resources = [:activity, :issues, :boards, :labels, :milestones,
- :merge_requests]
- links += resources.select do |resource|
- can?(current_user, "read_group_#{resource}".to_sym, @group)
- end
-
- # TODO Proper policies, such as `read_group_runners, should be implemented per
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
- if can?(current_user, :admin_group, @group) && Feature.enabled?(:runner_list_group_view_vue_ui, @group, default_enabled: :yaml)
- links << :runners
- end
-
- if can?(current_user, :read_cluster, @group)
- links << :kubernetes
- end
-
- if can?(current_user, :admin_group, @group)
- links << :settings
- end
-
- if can?(current_user, :read_wiki, @group)
- links << :wiki
- end
-
- links
- end
-
def group_title_link(group, hidable: false, show_avatar: false, for_dropdown: false)
link_to(group_path(group), class: "group-path #{'breadcrumb-item-text' unless for_dropdown} js-breadcrumb-item-text #{'hidable' if hidable}") do
icon = group_icon(group, class: "avatar-tile", width: 15, height: 15) if (group.try(:avatar_url) || show_avatar) && !Rails.env.test?
@@ -271,6 +196,18 @@ module GroupsHelper
def group_url_error_message
s_('GroupSettings|Please choose a group URL with no special characters or spaces.')
end
+
+ # Maps `jobs_to_be_done` values to option texts
+ def localized_jobs_to_be_done_choices
+ {
+ basics: _('I want to learn the basics of Git'),
+ move_repository: _('I want to move my repository to GitLab from somewhere else'),
+ code_storage: _('I want to store my code'),
+ exploring: _('I want to explore GitLab to see if it’s worth switching to'),
+ ci: _('I want to use GitLab CI with my existing repository'),
+ other: _('A different reason')
+ }.with_indifferent_access.freeze
+ end
end
GroupsHelper.prepend_mod_with('GroupsHelper')
diff --git a/app/helpers/invite_members_helper.rb b/app/helpers/invite_members_helper.rb
index 5134b484249..d9bd64f4c2e 100644
--- a/app/helpers/invite_members_helper.rb
+++ b/app/helpers/invite_members_helper.rb
@@ -9,14 +9,6 @@ module InviteMembersHelper
Feature.enabled?(:invite_members_group_modal, project.group) && can?(current_user, :admin_project_member, project)
end
- def can_invite_group_for_project?(project)
- # do not use the can_admin_project_member? helper here due to structure of the view and how membership_locked?
- # is leveraged for inviting groups
- Feature.enabled?(:invite_members_group_modal, project.group) &&
- can?(current_user, :admin_project_member, project) &&
- project.allowed_to_share_with_group?
- end
-
def invite_accepted_notice(member)
case member.source
when Project
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index d8ba530f3f6..f3cc46216e5 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -3,6 +3,7 @@
module IssuablesHelper
include GitlabRoutingHelper
include IssuablesDescriptionTemplatesHelper
+ include ::Sidebars::Concerns::HasPill
def sidebar_gutter_toggle_icon
content_tag(:span, class: 'js-sidebar-toggle-container', data: { is_expanded: !sidebar_gutter_collapsed? }) do
@@ -187,19 +188,18 @@ module IssuablesHelper
end
def issuables_state_counter_text(issuable_type, state, display_count)
- titles = {
- opened: "Open"
- }
-
+ titles = { opened: "Open" }
state_title = titles[state] || state.to_s.humanize
html = content_tag(:span, state_title)
return html.html_safe unless display_count
count = issuables_count_for_state(issuable_type, state)
-
if count != -1
- html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm')
+ html << " " << content_tag(:span,
+ format_count(issuable_type, count, Gitlab::IssuablesCountForState::THRESHOLD),
+ class: 'badge badge-muted badge-pill gl-badge gl-tab-counter-badge sm'
+ )
end
html.html_safe
@@ -256,7 +256,8 @@ module IssuablesHelper
issueType: issuable.issue_type,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier, # rubocop:disable CodeReuse/ActiveRecord
- iid: issuable.iid.to_s
+ iid: issuable.iid.to_s,
+ isHidden: issue_hidden?(issuable)
}
end
@@ -283,7 +284,9 @@ module IssuablesHelper
end
def issuables_count_for_state(issuable_type, state)
- Gitlab::IssuablesCountForState.new(finder)[state]
+ store_in_cache = parent.is_a?(Group) ? parent.cached_issues_state_count_enabled? : false
+
+ Gitlab::IssuablesCountForState.new(finder, store_in_redis_cache: store_in_cache)[state]
end
def close_issuable_path(issuable)
@@ -370,7 +373,7 @@ module IssuablesHelper
is_collapsed: is_collapsed,
track_label: "right_sidebar",
track_property: "update_todo",
- track_event: "click_button",
+ track_action: "click_button",
track_value: ""
}
end
@@ -437,6 +440,14 @@ module IssuablesHelper
def parent
@project || @group
end
+
+ def format_count(issuable_type, count, threshold)
+ if issuable_type == :issues && parent.is_a?(Group) && parent.cached_issues_state_count_enabled?
+ format_cached_count(threshold, count)
+ else
+ number_with_delimiter(count)
+ end
+ end
end
IssuablesHelper.prepend_mod_with('IssuablesHelper')
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index bbafdac9a7f..40e86b4623c 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -60,8 +60,16 @@ module IssuesHelper
sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential?
end
+ def issue_hidden?(issue)
+ Feature.enabled?(:ban_user_feature_flag) && issue.hidden?
+ end
+
def hidden_issue_icon(issue)
- sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom') if issue.hidden?
+ return unless issue_hidden?(issue)
+
+ content_tag(:span, class: 'has-tooltip', title: _('This issue is hidden because its author has been banned')) do
+ sprite_icon('spam', css_class: 'gl-vertical-align-text-bottom')
+ end
end
def award_user_list(awards, current_user, limit: 10)
@@ -174,7 +182,11 @@ module IssuesHelper
end
def issue_header_actions_data(project, issuable, current_user)
- new_issuable_params = ({ issuable_template: 'incident', issue: { issue_type: 'incident' } } if issuable.incident?)
+ new_issuable_params = { issue: { description: _('Related to #%{issue_id}.') % { issue_id: issuable.iid } + "\n\n" } }
+ if issuable.incident?
+ new_issuable_params[:issuable_template] = 'incident'
+ new_issuable_params[:issue][:issue_type] = 'incident'
+ end
{
can_create_issue: show_new_issue_link?(project).to_s,
@@ -191,34 +203,45 @@ module IssuesHelper
}
end
- def issues_list_data(project, current_user, finder)
+ def common_issues_list_data(namespace, current_user)
{
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)),
+ empty_state_svg_path: image_path('illustrations/issues.svg'),
+ full_path: namespace.full_path,
+ is_signed_in: current_user.present?.to_s,
+ jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
+ rss_path: url_for(safe_params.merge(rss_url_options)),
+ sign_in_path: new_user_session_path
+ }
+ end
+
+ def project_issues_list_data(project, current_user, finder)
+ common_issues_list_data(project, current_user).merge(
can_bulk_update: can?(current_user, :admin_issue, project).to_s,
can_edit: can?(current_user, :admin_project, project).to_s,
can_import_issues: can?(current_user, :import_issues, @project).to_s,
- email: current_user&.notification_email,
+ email: current_user&.notification_email_or_default,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
- empty_state_svg_path: image_path('illustrations/issues.svg'),
export_csv_path: export_csv_project_issues_path(project),
- has_project_issues: project_issues(project).exists?.to_s,
+ has_any_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path,
initial_email: project.new_issuable_address(current_user, 'issue'),
- is_signed_in: current_user.present?.to_s,
- issues_path: project_issues_path(project),
- jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
+ is_project: true.to_s,
markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { milestone_id: finder.milestones.first.try(:id) }),
project_import_jira_path: project_import_jira_path(project),
- project_path: project.full_path,
quick_actions_help_path: help_page_path('user/project/quick_actions'),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
- rss_path: url_for(safe_params.merge(rss_url_options)),
- show_new_issue_link: show_new_issue_link?(project).to_s,
- sign_in_path: new_user_session_path
- }
+ show_new_issue_link: show_new_issue_link?(project).to_s
+ )
+ end
+
+ def group_issues_list_data(group, current_user, issues)
+ common_issues_list_data(group, current_user).merge(
+ has_any_issues: issues.to_a.any?.to_s
+ )
end
# Overridden in EE
diff --git a/app/helpers/learn_gitlab_helper.rb b/app/helpers/learn_gitlab_helper.rb
index a3a8a275f67..4fb7a05a0e9 100644
--- a/app/helpers/learn_gitlab_helper.rb
+++ b/app/helpers/learn_gitlab_helper.rb
@@ -1,23 +1,12 @@
# frozen_string_literal: true
module LearnGitlabHelper
- def learn_gitlab_experiment_enabled?(project)
+ def learn_gitlab_enabled?(project)
return false unless current_user
- return false unless continous_onboarding_experiment_enabled_for_user?
learn_gitlab_onboarding_available?(project)
end
- def learn_gitlab_experiment_tracking_category
- return unless current_user
-
- if Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_a, subject: current_user)
- Gitlab::Experimentation.get_experiment(:learn_gitlab_a).tracking_category
- elsif Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_b, subject: current_user)
- Gitlab::Experimentation.get_experiment(:learn_gitlab_b).tracking_category
- end
- end
-
def onboarding_actions_data(project)
attributes = onboarding_progress(project).attributes.symbolize_keys
@@ -31,11 +20,6 @@ module LearnGitlabHelper
end
end
- def continous_onboarding_experiment_enabled_for_user?
- Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_a, subject: current_user) ||
- Gitlab::Experimentation.in_experiment_group?(:learn_gitlab_b, subject: current_user)
- end
-
def onboarding_sections_data
{
workspace: {
diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb
index 0384f82f1f1..e7d69c38a54 100644
--- a/app/helpers/nav/new_dropdown_helper.rb
+++ b/app/helpers/nav/new_dropdown_helper.rb
@@ -32,7 +32,7 @@ module Nav
id: 'new_project',
title: _('New project/repository'),
href: new_project_path(namespace_id: group.id),
- data: { track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' }
)
)
end
@@ -43,7 +43,7 @@ module Nav
id: 'new_subgroup',
title: _('New subgroup'),
href: new_group_path(parent_id: group.id),
- data: { track_event: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' }
)
)
end
@@ -74,7 +74,7 @@ module Nav
id: 'new_issue',
title: _('New issue'),
href: new_project_issue_path(project),
- data: { track_event: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' }
+ data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' }
)
)
end
@@ -85,7 +85,7 @@ module Nav
id: 'new_mr',
title: _('New merge request'),
href: project_new_merge_request_path(merge_project),
- data: { track_event: 'click_link_new_mr', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_mr', track_label: 'plus_menu_dropdown' }
)
)
end
@@ -96,7 +96,7 @@ module Nav
id: 'new_snippet',
title: _('New snippet'),
href: new_project_snippet_path(project),
- data: { track_event: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' }
)
)
end
@@ -124,7 +124,7 @@ module Nav
id: 'general_new_project',
title: _('New project/repository'),
href: new_project_path,
- data: { track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' }
+ data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' }
)
)
end
@@ -135,7 +135,7 @@ module Nav
id: 'general_new_group',
title: _('New group'),
href: new_group_path,
- data: { track_event: 'click_link_new_group', track_label: 'plus_menu_dropdown' }
+ data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown' }
)
)
end
@@ -146,7 +146,7 @@ module Nav
id: 'general_new_snippet',
title: _('New snippet'),
href: new_snippet_path,
- data: { track_event: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' }
+ data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' }
)
)
end
@@ -164,7 +164,7 @@ module Nav
emoji: ('shaking_hands' if experiment_enabled?(:invite_members_new_dropdown)),
href: href,
data: {
- track_event: 'click_link',
+ track_action: 'click_link',
track_label: tracking_label,
track_property: experiment_tracking_category_and_group(:invite_members_new_dropdown)
}
diff --git a/app/helpers/nav/top_nav_helper.rb b/app/helpers/nav/top_nav_helper.rb
index 052b8339ebd..3055ad57b80 100644
--- a/app/helpers/nav/top_nav_helper.rb
+++ b/app/helpers/nav/top_nav_helper.rb
@@ -98,7 +98,7 @@ module Nav
builder.add_primary_menu_item_with_shortcut(
active: nav == 'project' || active_nav_link?(path: %w[root#index projects#trending projects#starred dashboard/projects#index]),
css_class: 'qa-projects-dropdown',
- data: { track_label: "projects_dropdown", track_event: "click_dropdown" },
+ data: { track_label: "projects_dropdown", track_action: "click_dropdown" },
view: PROJECTS_VIEW,
shortcut_href: dashboard_projects_path,
**projects_menu_item_attrs
@@ -112,7 +112,7 @@ module Nav
builder.add_primary_menu_item_with_shortcut(
active: nav == 'group' || active_nav_link?(path: %w[dashboard/groups explore/groups]),
css_class: 'qa-groups-dropdown',
- data: { track_label: "groups_dropdown", track_event: "click_dropdown" },
+ data: { track_label: "groups_dropdown", track_action: "click_dropdown" },
view: GROUPS_VIEW,
shortcut_href: dashboard_groups_path,
**groups_menu_item_attrs
diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb
index c0ba93f4a30..ed96f3cef4f 100644
--- a/app/helpers/notify_helper.rb
+++ b/app/helpers/notify_helper.rb
@@ -20,4 +20,21 @@ module NotifyHelper
(source.description || default_description).truncate(200, separator: ' ')
end
+
+ def invited_join_url(token, member)
+ additional_params = { invite_type: Emails::Members::INITIAL_INVITE }
+
+ # order important below to our scheduled testing of these
+ # `from` experiment will be after the `text` on, but we may not cleanup
+ # from the `text` one by the time we run the `from` experiment,
+ # therefore we want to support `text` being fully enabled
+ # but if `from` is also enabled, then we only care about `from`
+ if experiment(:invite_email_from, actor: member).enabled?
+ additional_params[:experiment_name] = 'invite_email_from'
+ elsif experiment(:invite_email_preview_text, actor: member).enabled?
+ additional_params[:experiment_name] = 'invite_email_preview_text'
+ end
+
+ invite_url(token, additional_params)
+ end
end
diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb
index 1a466c9d170..ebf30fb3538 100644
--- a/app/helpers/packages_helper.rb
+++ b/app/helpers/packages_helper.rb
@@ -64,9 +64,8 @@ module PackagesHelper
project.container_repositories.exists?
end
- def package_details_data(project, package, use_presenter = false)
+ def package_details_data(project, package)
{
- package: use_presenter ? package_from_presenter(package) : nil,
package_id: package.id,
can_delete: can?(current_user, :destroy_package, project).to_s,
svg_path: image_path('illustrations/no-packages.svg'),
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index f6ed567c9ea..09fc1ab9d50 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -6,15 +6,12 @@ module ProfilesHelper
verified_emails = user.verified_emails - [private_email]
[
+ [s_('Use primary email (%{email})') % { email: user.email }, ''],
[s_("Profiles|Use a private email - %{email}").html_safe % { email: private_email }, Gitlab::PrivateCommitEmail::TOKEN],
*verified_emails
]
end
- def selected_commit_email(user)
- user.read_attribute(:commit_email) || user.commit_email
- end
-
def attribute_provider_label(attribute)
user_synced_attributes_metadata = current_user.user_synced_attributes_metadata
if user_synced_attributes_metadata&.synced?(attribute)
@@ -38,6 +35,21 @@ module ProfilesHelper
status&.availability == availability_values[:busy]
end
+ def middle_dot_divider_classes(stacking, breakpoint)
+ ['gl-mb-3'].tap do |classes|
+ if stacking
+ classes.concat(%w(middle-dot-divider-sm gl-display-block gl-sm-display-inline-block))
+ else
+ classes << 'gl-display-inline-block'
+ classes << if breakpoint.nil?
+ 'middle-dot-divider'
+ else
+ "middle-dot-divider-#{breakpoint}"
+ end
+ end
+ end
+ end
+
# Overridden in EE::ProfilesHelper#ssh_key_expiration_tooltip
def ssh_key_expiration_tooltip(key)
return key.errors.full_messages.join(', ') if key.errors.full_messages.any?
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index f30223f6f1e..d7f1cd505e9 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -435,7 +435,7 @@ module ProjectsHelper
def git_user_email
if current_user
- current_user.commit_email
+ current_user.commit_email_or_default
else
"your@email.com"
end
diff --git a/app/helpers/recaptcha_helper.rb b/app/helpers/recaptcha_helper.rb
index 4ebac1d5b7f..0df62f7b715 100644
--- a/app/helpers/recaptcha_helper.rb
+++ b/app/helpers/recaptcha_helper.rb
@@ -5,3 +5,5 @@ module RecaptchaHelper
!!Gitlab::Recaptcha.enabled?
end
end
+
+RecaptchaHelper.prepend_mod
diff --git a/app/helpers/routing/pseudonymization_helper.rb b/app/helpers/routing/pseudonymization_helper.rb
new file mode 100644
index 00000000000..1d9320f0106
--- /dev/null
+++ b/app/helpers/routing/pseudonymization_helper.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Routing
+ module PseudonymizationHelper
+ def masked_page_url
+ return unless Feature.enabled?(:mask_page_urls, type: :ops)
+
+ mask_params(Rails.application.routes.recognize_path(request.original_fullpath))
+ rescue ActionController::RoutingError, URI::InvalidURIError
+ nil
+ end
+
+ private
+
+ def mask_params(request_params)
+ return if request_params[:action] == 'new'
+
+ namespace_type = request_params[:controller].split('/')[1]
+
+ namespace_type.present? ? url_with_namespace_type(request_params, namespace_type) : url_without_namespace_type(request_params)
+ end
+
+ def url_without_namespace_type(request_params)
+ masked_url = "#{request.protocol}#{request.host_with_port}"
+
+ masked_url += case request_params[:controller]
+ when 'groups'
+ "/namespace:#{group.id}"
+ when 'projects'
+ "/namespace:#{project.namespace.id}/project:#{project.id}"
+ when 'root'
+ ''
+ else
+ "#{request.path}"
+ end
+
+ masked_url += request.query_string.present? ? "?#{request.query_string}" : ''
+
+ masked_url
+ end
+
+ def url_with_namespace_type(request_params, namespace_type)
+ masked_url = "#{request.protocol}#{request.host_with_port}"
+
+ if request_params.has_key?(:project_id)
+ masked_url += "/namespace:#{project.namespace.id}/project:#{project.id}/-/#{namespace_type}"
+ end
+
+ if request_params.has_key?(:id)
+ masked_url += namespace_type == 'blob' ? '/:repository_path' : "/#{request_params[:id]}"
+ end
+
+ masked_url += request.query_string.present? ? "?#{request.query_string}" : ''
+
+ masked_url
+ end
+ end
+end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 409a3e65fe3..b8e58e3afb1 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -443,6 +443,10 @@ module SearchHelper
_("Open")
end
end
+
+ def feature_flag_tab_enabled?(flag)
+ @group || Feature.enabled?(flag, current_user, type: :ops, default_enabled: true)
+ end
end
SearchHelper.prepend_mod_with('SearchHelper')
diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb
index 117f662fec6..e9466a9e97e 100644
--- a/app/helpers/sessions_helper.rb
+++ b/app/helpers/sessions_helper.rb
@@ -22,11 +22,21 @@ module SessionsHelper
# creates a new session after login, so the short TTL doesn't even need to
# be extended.
def limit_session_time
+ set_session_time(Settings.gitlab['unauthenticated_session_expire_delay'])
+ end
+
+ def ensure_authenticated_session_time
+ set_session_time(nil)
+ end
+
+ def set_session_time(expiry_s)
# Rack sets this header, but not all tests may have it: https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L251-L259
return unless request.env['rack.session.options']
- # This works because Rack uses these options every time a request is handled:
- # https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L342
- request.env['rack.session.options'][:expire_after] = Settings.gitlab['unauthenticated_session_expire_delay']
+ # This works because Rack uses these options every time a request is handled, and redis-store
+ # uses the Rack setting first:
+ # 1. https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L342
+ # 2. https://github.com/redis-store/redis-store/blob/3acfa95f4eb6260c714fdb00a3d84be8eedc13b2/lib/redis/store/ttl.rb#L32
+ request.env['rack.session.options'][:expire_after] = expiry_s
end
end
diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb
index 77af6e37099..9002fdda128 100644
--- a/app/helpers/sidebars_helper.rb
+++ b/app/helpers/sidebars_helper.rb
@@ -87,8 +87,7 @@ module SidebarsHelper
{
current_user: user,
container: project,
- learn_gitlab_experiment_enabled: learn_gitlab_experiment_enabled?(project),
- learn_gitlab_experiment_tracking_category: learn_gitlab_experiment_tracking_category,
+ learn_gitlab_enabled: learn_gitlab_enabled?(project),
current_ref: current_ref,
jira_issues_integration: project_jira_issues_integration?,
can_view_pipeline_editor: can_view_pipeline_editor?(project),
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 7fa85d143f7..b28e5ff39b2 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -37,7 +37,8 @@ module SortingHelper
sort_value_contacted_date => sort_title_contacted_date,
sort_value_relative_position => sort_title_relative_position,
sort_value_size => sort_title_size,
- sort_value_expire_date => sort_title_expire_date
+ sort_value_expire_date => sort_title_expire_date,
+ sort_value_title => sort_title_title
}
end
# rubocop: enable Metrics/AbcSize
@@ -188,7 +189,8 @@ module SortingHelper
sort_value_due_date_later => sort_value_due_date,
sort_value_merged_recently => sort_value_merged_date,
sort_value_closed_recently => sort_value_closed_date,
- sort_value_least_popular => sort_value_popularity
+ sort_value_least_popular => sort_value_popularity,
+ sort_value_title_desc => sort_value_title
}
end
@@ -205,7 +207,8 @@ module SortingHelper
sort_value_closed_date => sort_value_closed_recently,
sort_value_closed_earlier => sort_value_closed_recently,
sort_value_popularity => sort_value_least_popular,
- sort_value_most_popular => sort_value_least_popular
+ sort_value_most_popular => sort_value_least_popular,
+ sort_value_title => sort_value_title_desc
}.merge(issuable_sort_option_overrides)
end
diff --git a/app/helpers/sorting_titles_values_helper.rb b/app/helpers/sorting_titles_values_helper.rb
index f4117d690f3..75ba6e8a153 100644
--- a/app/helpers/sorting_titles_values_helper.rb
+++ b/app/helpers/sorting_titles_values_helper.rb
@@ -138,6 +138,10 @@ module SortingTitlesValuesHelper
s_('SortOptions|Start soon')
end
+ def sort_title_title
+ s_('SortOptions|Title')
+ end
+
def sort_title_upvotes
s_('SortOptions|Most popular')
end
@@ -307,6 +311,14 @@ module SortingTitlesValuesHelper
'start_date_asc'
end
+ def sort_value_title
+ 'title_asc'
+ end
+
+ def sort_value_title_desc
+ 'title_desc'
+ end
+
def sort_value_upvotes
'upvotes_desc'
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 521423fbb94..1d8b657025c 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -39,7 +39,8 @@ module SystemNoteHelper
'alert_issue_added' => 'issues',
'new_alert_added' => 'warning',
'severity' => 'information-o',
- 'cloned' => 'documents'
+ 'cloned' => 'documents',
+ 'issue_type' => 'pencil-square'
}.freeze
def system_note_icon_name(note)
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index f5a74a3f57d..2c3dc243d85 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -9,6 +9,7 @@ module UserCalloutsHelper
FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version'
REGISTRATION_ENABLED_CALLOUT = 'registration_enabled_callout'
UNFINISHED_TAG_CLEANUP_CALLOUT = 'unfinished_tag_cleanup_callout'
+ INVITE_MEMBERS_BANNER = 'invite_members_banner'
def show_gke_cluster_integration_callout?(project)
active_nav_link?(controller: sidebar_operations_paths) &&
@@ -27,7 +28,7 @@ module UserCalloutsHelper
def render_dashboard_ultimate_trial(user)
end
- def render_account_recovery_regular_check
+ def render_two_factor_auth_recovery_settings_check
end
def show_suggest_popover?
@@ -53,7 +54,14 @@ module UserCalloutsHelper
!user_dismissed?(REGISTRATION_ENABLED_CALLOUT)
end
- def dismiss_account_recovery_regular_check
+ def dismiss_two_factor_auth_recovery_settings_check
+ end
+
+ def show_invite_banner?(group)
+ Ability.allowed?(current_user, :admin_group, group) &&
+ !just_created? &&
+ !user_dismissed_for_group(INVITE_MEMBERS_BANNER, group) &&
+ !multiple_members?(group)
end
private
@@ -63,6 +71,43 @@ module UserCalloutsHelper
current_user.dismissed_callout?(feature_name: feature_name, ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
end
+
+ def user_dismissed_for_group(feature_name, group, ignore_dismissal_earlier_than = nil)
+ return false unless current_user
+
+ set_dismissed_from_cookie(group)
+
+ current_user.dismissed_callout_for_group?(feature_name: feature_name,
+ group: group,
+ ignore_dismissal_earlier_than: ignore_dismissal_earlier_than)
+ end
+
+ def set_dismissed_from_cookie(group)
+ # bridge function for one milestone to try and not annoy users who might have already dismissed this alert
+ # remove in 14.4 or 14.5? https://gitlab.com/gitlab-org/gitlab/-/issues/340322
+ dismissed_key = "invite_#{group.id}_#{current_user.id}"
+
+ if cookies[dismissed_key].present?
+ params = {
+ feature_name: INVITE_MEMBERS_BANNER,
+ group_id: group.id
+ }
+
+ Users::DismissGroupCalloutService.new(
+ container: nil, current_user: current_user, params: params
+ ).execute
+
+ cookies.delete dismissed_key
+ end
+ end
+
+ def just_created?
+ flash[:notice]&.include?('successfully created')
+ end
+
+ def multiple_members?(group)
+ group.member_count > 1 || group.members_with_parents.count > 1
+ end
end
UserCalloutsHelper.prepend_mod
diff --git a/app/mailers/emails/admin_notification.rb b/app/mailers/emails/admin_notification.rb
index f4540ef81a5..e11f06d8fc9 100644
--- a/app/mailers/emails/admin_notification.rb
+++ b/app/mailers/emails/admin_notification.rb
@@ -4,7 +4,7 @@ module Emails
module AdminNotification
def send_admin_notification(user_id, subject, body)
user = User.find(user_id)
- email = user.notification_email
+ email = user.notification_email_or_default
@unsubscribe_url = unsubscribe_url(email: Base64.urlsafe_encode64(email))
@body = body
mail to: email, subject: subject
@@ -12,7 +12,7 @@ module Emails
def send_unsubscribed_notification(user_id)
user = User.find(user_id)
- email = user.notification_email
+ email = user.notification_email_or_default
mail to: email, subject: "Unsubscribed from GitLab administrator notifications"
end
end
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index fe2d2891547..6954fd46850 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -57,7 +57,7 @@ module Emails
Gitlab::Tracking.event(self.class.name, 'invite_email_sent', label: 'invite_email', property: member_id.to_s)
- mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers) do |format|
+ mail(to: member.invite_email, subject: invite_email_subject, **invite_email_headers.merge(additional_invite_settings)) do |format|
format.html { render layout: 'unknown_user_mailer' }
format.text { render layout: 'unknown_user_mailer' }
end
@@ -147,23 +147,48 @@ module Emails
def invite_email_subject
if member.created_by
- subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
+ experiment(:invite_email_from, actor: member) do |experiment_instance|
+ experiment_instance.use do
+ subject(s_("MemberInviteEmail|%{member_name} invited you to join GitLab") % { member_name: member.created_by.name })
+ end
+
+ experiment_instance.candidate do
+ subject(s_("MemberInviteEmail|I've invited you to join me in GitLab"))
+ end
+
+ experiment_instance.run
+ end
else
subject(s_("MemberInviteEmail|Invitation to join the %{project_or_group} %{project_or_group_name}") % { project_or_group: member_source.human_name, project_or_group_name: member_source.model_name.singular })
end
end
def invite_email_headers
- if Gitlab.dev_env_or_com?
+ if Gitlab::CurrentSettings.mailgun_events_enabled?
{
- 'X-Mailgun-Tag' => 'invite_email',
- 'X-Mailgun-Variables' => { 'invite_token' => @token }.to_json
+ 'X-Mailgun-Tag' => ::Members::Mailgun::INVITE_EMAIL_TAG,
+ 'X-Mailgun-Variables' => { ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY => @token }.to_json
}
else
{}
end
end
+ def additional_invite_settings
+ return {} unless member.created_by
+
+ experiment(:invite_email_from, actor: member) do |experiment_instance|
+ experiment_instance.use { {} }
+ experiment_instance.candidate do
+ {
+ from: "#{member.created_by.name} <#{member.created_by.email}>"
+ }
+ end
+
+ experiment_instance.run
+ end
+ end
+
def member_exists?
Gitlab::AppLogger.info("Tried to send an email invitation for a deleted group. Member id: #{@member_id}") if member.blank?
member.present?
diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb
index a8affb34f62..592c394bb48 100644
--- a/app/mailers/emails/profile.rb
+++ b/app/mailers/emails/profile.rb
@@ -6,7 +6,7 @@ module Emails
@current_user = @user = User.find(user_id)
@target_url = user_url(@user)
@token = token
- mail(to: @user.notification_email, subject: subject("Account was created for you"))
+ mail(to: @user.notification_email_or_default, subject: subject("Account was created for you"))
end
def instance_access_request_email(user, recipient)
@@ -14,7 +14,7 @@ module Emails
@recipient = recipient
profile_email_with_layout(
- to: recipient.notification_email,
+ to: recipient.notification_email_or_default,
subject: subject(_("GitLab Account Request")))
end
@@ -42,7 +42,7 @@ module Emails
@current_user = @user = @key.user
@target_url = user_url(@user)
- mail(to: @user.notification_email, subject: subject("SSH key was added to your account"))
+ mail(to: @user.notification_email_or_default, subject: subject("SSH key was added to your account"))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -54,7 +54,7 @@ module Emails
@current_user = @user = @gpg_key.user
@target_url = user_url(@user)
- mail(to: @user.notification_email, subject: subject("GPG key was added to your account"))
+ mail(to: @user.notification_email_or_default, subject: subject("GPG key was added to your account"))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -67,7 +67,7 @@ module Emails
@days_to_expire = PersonalAccessToken::DAYS_TO_EXPIRE
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
+ mail(to: @user.notification_email_or_default, subject: subject(_("Your personal access tokens will expire in %{days_to_expire} days or less") % { days_to_expire: @days_to_expire }))
end
end
@@ -78,7 +78,7 @@ module Emails
@target_url = profile_personal_access_tokens_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email, subject: subject(_("Your personal access token has expired")))
+ mail(to: @user.notification_email_or_default, subject: subject(_("Your personal access token has expired")))
end
end
@@ -90,7 +90,7 @@ module Emails
@target_url = profile_keys_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email, subject: subject(_("Your SSH key has expired")))
+ mail(to: @user.notification_email_or_default, subject: subject(_("Your SSH key has expired")))
end
end
@@ -102,7 +102,7 @@ module Emails
@target_url = profile_keys_url
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email, subject: subject(_("Your SSH key is expiring soon.")))
+ mail(to: @user.notification_email_or_default, subject: subject(_("Your SSH key is expiring soon.")))
end
end
@@ -114,7 +114,7 @@ module Emails
Gitlab::I18n.with_locale(@user.preferred_language) do
profile_email_with_layout(
- to: @user.notification_email,
+ to: @user.notification_email_or_default,
subject: subject(_("%{host} sign-in from new location") % { host: Gitlab.config.gitlab.host }))
end
end
@@ -125,7 +125,7 @@ module Emails
@user = user
Gitlab::I18n.with_locale(@user.preferred_language) do
- mail(to: @user.notification_email, subject: subject(_("Two-factor authentication disabled")))
+ mail(to: @user.notification_email_or_default, subject: subject(_("Two-factor authentication disabled")))
end
end
diff --git a/app/models/analytics/cycle_analytics/issue_stage_event.rb b/app/models/analytics/cycle_analytics/issue_stage_event.rb
new file mode 100644
index 00000000000..1da8973ff21
--- /dev/null
+++ b/app/models/analytics/cycle_analytics/issue_stage_event.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Analytics
+ module CycleAnalytics
+ class IssueStageEvent < ApplicationRecord
+ extend SuppressCompositePrimaryKeyWarning
+
+ validates(*%i[stage_event_hash_id issue_id group_id project_id start_event_timestamp], presence: true)
+ end
+ end
+end
diff --git a/app/models/analytics/cycle_analytics/merge_request_stage_event.rb b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb
new file mode 100644
index 00000000000..d2f899ae933
--- /dev/null
+++ b/app/models/analytics/cycle_analytics/merge_request_stage_event.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Analytics
+ module CycleAnalytics
+ class MergeRequestStageEvent < ApplicationRecord
+ extend SuppressCompositePrimaryKeyWarning
+
+ validates(*%i[stage_event_hash_id merge_request_id group_id project_id start_event_timestamp], presence: true)
+ end
+ end
+end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index d9375b55e89..d2757d8c17d 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class ApplicationRecord < ActiveRecord::Base
+ self.gitlab_schema = :gitlab_main
self.abstract_class = true
alias_method :reset, :reload
@@ -30,7 +31,7 @@ class ApplicationRecord < ActiveRecord::Base
end
def self.safe_ensure_unique(retries: 0)
- transaction(requires_new: true) do
+ transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
yield
end
rescue ActiveRecord::RecordNotUnique
@@ -54,7 +55,7 @@ class ApplicationRecord < ActiveRecord::Base
# currently one third of the default 15-second timeout
def self.with_fast_read_statement_timeout(timeout_ms = 5000)
::Gitlab::Database::LoadBalancing::Session.current.fallback_to_replicas_for_ambiguous_queries do
- transaction(requires_new: true) do
+ transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
connection.exec_query("SET LOCAL statement_timeout = #{timeout_ms}")
yield
@@ -63,14 +64,6 @@ class ApplicationRecord < ActiveRecord::Base
end
def self.safe_find_or_create_by(*args, &block)
- return optimized_safe_find_or_create_by(*args, &block) if Feature.enabled?(:optimize_safe_find_or_create_by, default_enabled: :yaml)
-
- safe_ensure_unique(retries: 1) do
- find_or_create_by(*args, &block)
- end
- end
-
- def self.optimized_safe_find_or_create_by(*args, &block)
record = find_by(*args)
return record if record.present?
@@ -79,7 +72,7 @@ class ApplicationRecord < ActiveRecord::Base
#
# When calling this method on an association, just calling `self.create` would call `ActiveRecord::Persistence.create`
# and that skips some code that adds the newly created record to the association.
- transaction(requires_new: true) { all.create(*args, &block) }
+ transaction(requires_new: true) { all.create(*args, &block) } # rubocop:disable Performance/ActiveRecordSubtransactions
rescue ActiveRecord::RecordNotUnique
find_by(*args)
end
@@ -103,23 +96,18 @@ class ApplicationRecord < ActiveRecord::Base
enum(enum_mod.key => values)
end
- def self.transaction(**options, &block)
- if options[:requires_new] && track_subtransactions?
- ::Gitlab::Database::Metrics.subtransactions_increment(self.name)
- end
-
- super(**options, &block)
- end
-
- def self.track_subtransactions?
- ::Feature.enabled?(:active_record_subtransactions_counter, type: :ops, default_enabled: :yaml) &&
- connection.transaction_open?
- end
-
def self.cached_column_list
self.column_names.map { |column_name| self.arel_table[column_name] }
end
+ def self.default_select_columns
+ if ignored_columns.any?
+ cached_column_list
+ else
+ arel_table[Arel.star]
+ end
+ end
+
def readable_by?(user)
Ability.allowed?(user, "read_#{to_ability_name}".to_sym, self)
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index c4b6bcb9395..5f16b990d01 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -6,6 +6,7 @@ class ApplicationSetting < ApplicationRecord
include TokenAuthenticatable
include ChronicDurationAttribute
include IgnorableColumns
+ include Sanitizable
ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22'
ignore_column :seat_link_enabled, remove_with: '14.4', remove_after: '2021-09-22'
@@ -32,6 +33,8 @@ class ApplicationSetting < ApplicationRecord
alias_attribute :instance_group_id, :instance_administrators_group_id
alias_attribute :instance_administrators_group, :instance_group
+ sanitizes! :default_branch_name
+
def self.kroki_formats_attributes
{
blockdiag: {
@@ -204,6 +207,10 @@ class ApplicationSetting < ApplicationRecord
numericality: { only_integer: true, greater_than_or_equal_to: 0,
less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte }
+ validates :jobs_per_stage_page_size,
+ presence: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
validates :default_artifacts_expire_in, presence: true, duration: true
validates :container_expiration_policies_enable_historic_entries,
@@ -343,6 +350,8 @@ class ApplicationSetting < ApplicationRecord
validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 }
validates :wiki_page_max_content_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 1.kilobytes }
+ validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true
+ validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true
validates :email_restrictions, untrusted_regexp: true
@@ -463,53 +472,28 @@ class ApplicationSetting < ApplicationRecord
length: { maximum: 255, message: _('is too long (maximum is %{count} characters)') },
allow_blank: true
- validates :throttle_unauthenticated_requests_per_period,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_unauthenticated_period_in_seconds,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_unauthenticated_packages_api_requests_per_period,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_unauthenticated_packages_api_period_in_seconds,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_authenticated_api_requests_per_period,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_authenticated_api_period_in_seconds,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_authenticated_web_requests_per_period,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_authenticated_web_period_in_seconds,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_authenticated_packages_api_requests_per_period,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_authenticated_packages_api_period_in_seconds,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_protected_paths_requests_per_period,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
-
- validates :throttle_protected_paths_period_in_seconds,
- presence: true,
- numericality: { only_integer: true, greater_than: 0 }
+ with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do
+ validates :throttle_unauthenticated_api_requests_per_period
+ validates :throttle_unauthenticated_api_period_in_seconds
+ validates :throttle_unauthenticated_requests_per_period
+ validates :throttle_unauthenticated_period_in_seconds
+ validates :throttle_unauthenticated_packages_api_requests_per_period
+ validates :throttle_unauthenticated_packages_api_period_in_seconds
+ validates :throttle_unauthenticated_files_api_requests_per_period
+ validates :throttle_unauthenticated_files_api_period_in_seconds
+ validates :throttle_authenticated_api_requests_per_period
+ validates :throttle_authenticated_api_period_in_seconds
+ validates :throttle_authenticated_git_lfs_requests_per_period
+ validates :throttle_authenticated_git_lfs_period_in_seconds
+ validates :throttle_authenticated_web_requests_per_period
+ validates :throttle_authenticated_web_period_in_seconds
+ validates :throttle_authenticated_packages_api_requests_per_period
+ validates :throttle_authenticated_packages_api_period_in_seconds
+ validates :throttle_authenticated_files_api_requests_per_period
+ validates :throttle_authenticated_files_api_period_in_seconds
+ validates :throttle_protected_paths_requests_per_period
+ validates :throttle_protected_paths_period_in_seconds
+ end
validates :notes_create_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
@@ -534,6 +518,18 @@ class ApplicationSetting < ApplicationRecord
validates :floc_enabled,
inclusion: { in: [true, false], message: _('must be a boolean value') }
+ enum sidekiq_job_limiter_mode: {
+ Gitlab::SidekiqMiddleware::SizeLimiter::Validator::TRACK_MODE => 0,
+ Gitlab::SidekiqMiddleware::SizeLimiter::Validator::COMPRESS_MODE => 1 # The default
+ }
+
+ validates :sidekiq_job_limiter_mode,
+ inclusion: { in: self.sidekiq_job_limiter_modes }
+ validates :sidekiq_job_limiter_compression_threshold_bytes,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :sidekiq_job_limiter_limit_bytes,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -573,7 +569,7 @@ class ApplicationSetting < ApplicationRecord
before_validation :ensure_uuid!
before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed?
- before_validation :sanitize_default_branch_name
+ before_validation :normalize_default_branch_name
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@@ -603,12 +599,8 @@ class ApplicationSetting < ApplicationRecord
!!(sourcegraph_url =~ %r{\Ahttps://(www\.)?sourcegraph\.com})
end
- def sanitize_default_branch_name
- self.default_branch_name = if default_branch_name.blank?
- nil
- else
- Sanitize.fragment(self.default_branch_name)
- end
+ def normalize_default_branch_name
+ self.default_branch_name = default_branch_name.presence
end
def instance_review_permitted?
@@ -622,7 +614,7 @@ class ApplicationSetting < ApplicationRecord
def self.create_from_defaults
check_schema!
- transaction(requires_new: true) do
+ transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
super
end
rescue ActiveRecord::RecordNotUnique
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 060c831a11b..612fda158d3 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -109,6 +109,8 @@ module ApplicationSettingImplementation
max_artifacts_size: Settings.artifacts['max_size'],
max_attachment_size: Settings.gitlab['max_attachment_size'],
max_import_size: 0,
+ max_yaml_size_bytes: 1.megabyte,
+ max_yaml_depth: 100,
minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH,
mirror_available: true,
notes_create_limit: 300,
@@ -161,24 +163,36 @@ module ApplicationSettingImplementation
throttle_authenticated_api_enabled: false,
throttle_authenticated_api_period_in_seconds: 3600,
throttle_authenticated_api_requests_per_period: 7200,
+ throttle_authenticated_git_lfs_enabled: false,
+ throttle_authenticated_git_lfs_period_in_seconds: 60,
+ throttle_authenticated_git_lfs_requests_per_period: 1000,
throttle_authenticated_web_enabled: false,
throttle_authenticated_web_period_in_seconds: 3600,
throttle_authenticated_web_requests_per_period: 7200,
throttle_authenticated_packages_api_enabled: false,
throttle_authenticated_packages_api_period_in_seconds: 15,
throttle_authenticated_packages_api_requests_per_period: 1000,
+ throttle_authenticated_files_api_enabled: false,
+ throttle_authenticated_files_api_period_in_seconds: 15,
+ throttle_authenticated_files_api_requests_per_period: 500,
throttle_incident_management_notification_enabled: false,
throttle_incident_management_notification_per_period: 3600,
throttle_incident_management_notification_period_in_seconds: 3600,
throttle_protected_paths_enabled: false,
throttle_protected_paths_in_seconds: 10,
throttle_protected_paths_per_period: 60,
+ throttle_unauthenticated_api_enabled: false,
+ throttle_unauthenticated_api_period_in_seconds: 3600,
+ throttle_unauthenticated_api_requests_per_period: 3600,
throttle_unauthenticated_enabled: false,
throttle_unauthenticated_period_in_seconds: 3600,
throttle_unauthenticated_requests_per_period: 3600,
throttle_unauthenticated_packages_api_enabled: false,
throttle_unauthenticated_packages_api_period_in_seconds: 15,
throttle_unauthenticated_packages_api_requests_per_period: 800,
+ throttle_unauthenticated_files_api_enabled: false,
+ throttle_unauthenticated_files_api_period_in_seconds: 15,
+ throttle_unauthenticated_files_api_requests_per_period: 125,
time_tracking_limit_to_hours: false,
two_factor_grace_period: 48,
unique_ips_limit_enabled: false,
@@ -197,7 +211,8 @@ module ApplicationSettingImplementation
kroki_url: nil,
kroki_formats: { blockdiag: false, bpmn: false, excalidraw: false },
rate_limiting_response_text: nil,
- whats_new_variant: 0
+ whats_new_variant: 0,
+ user_deactivation_emails_enabled: true
}
end
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index c8f6b9aaedb..d251b0adbd3 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -66,3 +66,5 @@ class AwardEmoji < ApplicationRecord
awardable.try(:update_upvotes_count) if upvote?
end
end
+
+AwardEmoji.prepend_mod_with('AwardEmoji')
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 24f86b44841..ab5d248ff8c 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -78,6 +78,30 @@ class BulkImports::Entity < ApplicationRecord
ERB::Util.url_encode(source_full_path)
end
+ def pipelines
+ @pipelines ||= case source_type
+ when 'group_entity'
+ BulkImports::Groups::Stage.pipelines
+ when 'project_entity'
+ BulkImports::Projects::Stage.pipelines
+ end
+ end
+
+ def pipeline_exists?(name)
+ pipelines.any? { |_, pipeline| pipeline.to_s == name.to_s }
+ end
+
+ def create_pipeline_trackers!
+ self.class.transaction do
+ pipelines.each do |stage, pipeline|
+ trackers.create!(
+ stage: stage,
+ pipeline_name: pipeline
+ )
+ end
+ end
+ end
+
private
def validate_parent_is_a_group
diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb
index 1b108d5c042..c185470b1c2 100644
--- a/app/models/bulk_imports/tracker.rb
+++ b/app/models/bulk_imports/tracker.rb
@@ -34,8 +34,8 @@ class BulkImports::Tracker < ApplicationRecord
end
def pipeline_class
- unless BulkImports::Stage.pipeline_exists?(pipeline_name)
- raise NameError, "'#{pipeline_name}' is not a valid BulkImport Pipeline"
+ unless entity.pipeline_exists?(pipeline_name)
+ raise BulkImports::Error, "'#{pipeline_name}' is not a valid BulkImport Pipeline"
end
pipeline_name.constantize
diff --git a/app/models/ci/application_record.rb b/app/models/ci/application_record.rb
index 9d4a8f0648e..913e7a62c66 100644
--- a/app/models/ci/application_record.rb
+++ b/app/models/ci/application_record.rb
@@ -2,6 +2,7 @@
module Ci
class ApplicationRecord < ::ApplicationRecord
+ self.gitlab_schema = :gitlab_ci
self.abstract_class = true
def self.table_name_prefix
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 577bca282ef..97fb8233d34 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -28,10 +28,10 @@ module Ci
state_machine :status do
after_transition [:created, :manual, :waiting_for_resource] => :pending do |bridge|
- next unless bridge.downstream_project
+ next unless bridge.triggers_downstream_pipeline?
bridge.run_after_commit do
- bridge.schedule_downstream_pipeline!
+ ::Ci::CreateCrossProjectPipelineWorker.perform_async(bridge.id)
end
end
@@ -64,12 +64,6 @@ module Ci
)
end
- def schedule_downstream_pipeline!
- raise InvalidBridgeTypeError unless downstream_project
-
- ::Ci::CreateCrossProjectPipelineWorker.perform_async(self.id)
- end
-
def inherit_status_from_downstream!(pipeline)
case pipeline.status
when 'success'
@@ -112,10 +106,18 @@ module Ci
pipeline if triggers_child_pipeline?
end
+ def triggers_downstream_pipeline?
+ triggers_child_pipeline? || triggers_cross_project_pipeline?
+ end
+
def triggers_child_pipeline?
yaml_for_downstream.present?
end
+ def triggers_cross_project_pipeline?
+ downstream_project_path.present?
+ end
+
def tags
[:bridge]
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 1ca291a659b..e2e24247679 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -90,6 +90,10 @@ module Ci
end
end
+ def persisted_environment=(environment)
+ strong_memoize(:persisted_environment) { environment }
+ end
+
serialize :options # rubocop:disable Cop/ActiveRecordSerialize
serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiveRecordSerialize
@@ -166,8 +170,6 @@ module Ci
scope :with_stale_live_trace, -> { with_live_trace.finished_before(12.hours.ago) }
scope :finished_before, -> (date) { finished.where('finished_at < ?', date) }
- scope :with_secure_reports_from_options, -> (job_type) { where('options like :job_type', job_type: "%:artifacts:%:reports:%:#{job_type}:%") }
-
scope :with_secure_reports_from_config_options, -> (job_types) do
joins(:metadata).where("ci_builds_metadata.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types)
end
@@ -306,7 +308,9 @@ module Ci
end
after_transition pending: :running do |build|
- build.deployment&.run
+ Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
+ build.deployment&.run
+ end
build.run_after_commit do
build.pipeline.persistent_ref.create
@@ -328,7 +332,9 @@ module Ci
end
after_transition any => [:success] do |build|
- build.deployment&.succeed
+ Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
+ build.deployment&.succeed
+ end
build.run_after_commit do
BuildSuccessWorker.perform_async(id)
@@ -341,7 +347,9 @@ module Ci
next unless build.deployment
begin
- build.deployment.drop!
+ Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
+ build.deployment.drop!
+ end
rescue StandardError => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id)
end
@@ -362,10 +370,12 @@ module Ci
end
after_transition any => [:skipped, :canceled] do |build, transition|
- if transition.to_name == :skipped
- build.deployment&.skip
- else
- build.deployment&.cancel
+ Gitlab::Database.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338867') do
+ if transition.to_name == :skipped
+ build.deployment&.skip
+ else
+ build.deployment&.cancel
+ end
end
end
end
@@ -712,6 +722,10 @@ module Ci
update_column(:trace, nil)
end
+ def ensure_trace_metadata!
+ Ci::BuildTraceMetadata.find_or_upsert_for!(id)
+ end
+
def artifacts_expose_as
options.dig(:artifacts, :expose_as)
end
@@ -748,7 +762,9 @@ module Ci
def any_runners_available?
cache_for_available_runners do
- project.active_runners.exists?
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937') do
+ project.active_runners.exists?
+ end
end
end
@@ -1013,9 +1029,10 @@ module Ci
# Consider this object to have a structural integrity problems
def doom!
- update_columns(
- status: :failed,
- failure_reason: :data_integrity_failure)
+ transaction do
+ update_columns(status: :failed, failure_reason: :data_integrity_failure)
+ all_queuing_entries.delete_all
+ end
end
def degradation_threshold
diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb
index 3bfac2b33c0..1cae2279434 100644
--- a/app/models/ci/build_trace_chunks/fog.rb
+++ b/app/models/ci/build_trace_chunks/fog.rb
@@ -80,12 +80,10 @@ module Ci
private
def append_strings(old_data, new_data)
- if Feature.enabled?(:ci_job_trace_force_encode, default_enabled: :yaml)
- # When object storage is in use, old_data may be retrieved in UTF-8.
- old_data = old_data.force_encoding(Encoding::ASCII_8BIT)
- # new_data should already be in ASCII-8BIT, but just in case it isn't, do this.
- new_data = new_data.force_encoding(Encoding::ASCII_8BIT)
- end
+ # When object storage is in use, old_data may be retrieved in UTF-8.
+ old_data = old_data.force_encoding(Encoding::ASCII_8BIT)
+ # new_data should already be in ASCII-8BIT, but just in case it isn't, do this.
+ new_data = new_data.force_encoding(Encoding::ASCII_8BIT)
old_data + new_data
end
diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb
index 05bdb3d8b7b..901b84ceec6 100644
--- a/app/models/ci/build_trace_metadata.rb
+++ b/app/models/ci/build_trace_metadata.rb
@@ -2,6 +2,7 @@
module Ci
class BuildTraceMetadata < Ci::ApplicationRecord
+ MAX_ATTEMPTS = 5
self.table_name = 'ci_build_trace_metadata'
self.primary_key = :build_id
@@ -9,5 +10,49 @@ module Ci
belongs_to :trace_artifact, class_name: 'Ci::JobArtifact'
validates :build, presence: true
+ validates :archival_attempts, presence: true
+
+ def self.find_or_upsert_for!(build_id)
+ record = find_by(build_id: build_id)
+ return record if record
+
+ upsert({ build_id: build_id }, unique_by: :build_id)
+ find_by!(build_id: build_id)
+ end
+
+ # The job is retried around 5 times during the 7 days retention period for
+ # trace chunks as defined in `Ci::BuildTraceChunks::RedisBase::CHUNK_REDIS_TTL`
+ def can_attempt_archival_now?
+ return false unless archival_attempts_available?
+ return true unless last_archival_attempt_at
+
+ last_archival_attempt_at + backoff < Time.current
+ end
+
+ def archival_attempts_available?
+ archival_attempts <= MAX_ATTEMPTS
+ end
+
+ def increment_archival_attempts!
+ increment!(:archival_attempts, touch: :last_archival_attempt_at)
+ end
+
+ def track_archival!(trace_artifact_id)
+ update!(trace_artifact_id: trace_artifact_id, archived_at: Time.current)
+ end
+
+ def archival_attempts_message
+ if archival_attempts_available?
+ 'The job can not be archived right now.'
+ else
+ 'The job is out of archival attempts.'
+ end
+ end
+
+ private
+
+ def backoff
+ ::Gitlab::Ci::Trace::Backoff.new(archival_attempts).value_with_jitter
+ end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 1f0da4345f2..ad3e867f9d5 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -10,6 +10,9 @@ module Ci
include Artifactable
include FileStoreMounter
include EachBatch
+ include IgnorableColumns
+
+ ignore_columns %i[id_convert_to_bigint job_id_convert_to_bigint], remove_with: '14.5', remove_after: '2021-11-22'
TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
@@ -182,7 +185,6 @@ module Ci
scope :order_expired_desc, -> { order(expire_at: :desc) }
scope :with_destroy_preloads, -> { includes(project: [:route, :statistics]) }
- scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') }
scope :for_project, ->(project) { where(project_id: project) }
scope :created_in_time_range, ->(from: nil, to: nil) { where(created_at: from..to) }
@@ -232,6 +234,17 @@ module Ci
hashed_path: 2
}
+ # `locked` will be populated from the source of truth on Ci::Pipeline
+ # in order to clean up expired job artifacts in a performant way.
+ # The values should be the same as `Ci::Pipeline.lockeds` with the
+ # additional value of `unknown` to indicate rows that have not
+ # yet been populated from the parent Ci::Pipeline
+ enum locked: {
+ unlocked: 0,
+ artifacts_locked: 1,
+ unknown: 2
+ }, _prefix: :artifact
+
def validate_file_format!
unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym
errors.add(:base, _('Invalid file format with specified file type'))
diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb
index 7cf3a387516..ccad6290fac 100644
--- a/app/models/ci/pending_build.rb
+++ b/app/models/ci/pending_build.rb
@@ -2,6 +2,8 @@
module Ci
class PendingBuild < Ci::ApplicationRecord
+ include EachBatch
+
belongs_to :project
belongs_to :build, class_name: 'Ci::Build'
belongs_to :namespace, inverse_of: :pending_builds, class_name: 'Namespace'
@@ -11,52 +13,62 @@ module Ci
scope :ref_protected, -> { where(protected: true) }
scope :queued_before, ->(time) { where(arel_table[:created_at].lt(time)) }
scope :with_instance_runners, -> { where(instance_runners_enabled: true) }
+ scope :for_tags, ->(tag_ids) do
+ if tag_ids.present?
+ where('ci_pending_builds.tag_ids <@ ARRAY[?]::int[]', Array.wrap(tag_ids))
+ else
+ where("ci_pending_builds.tag_ids = '{}'")
+ end
+ end
- def self.upsert_from_build!(build)
- entry = self.new(args_from_build(build))
+ class << self
+ def upsert_from_build!(build)
+ entry = self.new(args_from_build(build))
- entry.validate!
+ entry.validate!
- self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id)
- end
+ self.upsert(entry.attributes.compact, returning: %w[build_id], unique_by: :build_id)
+ end
- def self.args_from_build(build)
- args = {
- build: build,
- project: build.project,
- protected: build.protected?,
- namespace: build.project.namespace
- }
+ private
+
+ def args_from_build(build)
+ project = build.project
+
+ args = {
+ build: build,
+ project: project,
+ protected: build.protected?,
+ namespace: project.namespace
+ }
+
+ if Feature.enabled?(:ci_pending_builds_maintain_tags_data, type: :development, default_enabled: :yaml)
+ args.store(:tag_ids, build.tags_ids)
+ end
+
+ if Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, type: :development, default_enabled: :yaml)
+ args.store(:instance_runners_enabled, shared_runners_enabled?(project))
+ end
+
+ if Feature.enabled?(:ci_pending_builds_maintain_namespace_traversal_ids, type: :development, default_enabled: :yaml)
+ args.store(:namespace_traversal_ids, project.namespace.traversal_ids) if group_runners_enabled?(project)
+ end
- if Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, type: :development, default_enabled: :yaml)
- args.merge(instance_runners_enabled: shareable?(build))
- else
args
end
- end
- private_class_method :args_from_build
-
- def self.shareable?(build)
- shared_runner_enabled?(build) &&
- builds_access_level?(build) &&
- project_not_removed?(build)
- end
- private_class_method :shareable?
- def self.shared_runner_enabled?(build)
- build.project.shared_runners.exists?
- end
- private_class_method :shared_runner_enabled?
+ def shared_runners_enabled?(project)
+ builds_enabled?(project) && project.shared_runners_enabled?
+ end
- def self.project_not_removed?(build)
- !build.project.pending_delete?
- end
- private_class_method :project_not_removed?
+ def group_runners_enabled?(project)
+ builds_enabled?(project) && project.group_runners_enabled?
+ end
- def self.builds_access_level?(build)
- build.project.project_feature.builds_access_level.nil? || build.project.project_feature.builds_access_level > 0
+ def builds_enabled?(project)
+ project.builds_enabled? && !project.pending_delete?
+ end
end
- private_class_method :builds_access_level?
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 70e67953e31..1a0cec3c935 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -66,6 +66,7 @@ module Ci
has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :bridges, class_name: 'Ci::Bridge', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :generic_commit_statuses, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'GenericCommitStatus'
has_many :job_artifacts, through: :builds
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
@@ -307,6 +308,7 @@ module Ci
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 :where_not_sha, -> (sha) { where.not(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) }
@@ -317,7 +319,6 @@ module Ci
scope :created_after, -> (time) { where('ci_pipelines.created_at > ?', time) }
scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) }
scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) }
- scope :eager_load_project, -> { eager_load(project: [:route, { namespace: :route }]) }
scope :with_pipeline_source, -> (source) { where(source: source)}
scope :outside_pipeline_family, ->(pipeline) do
@@ -588,13 +589,11 @@ module Ci
end
def cancel_running(retries: 1)
- commit_status_relations = [:project, :pipeline]
- ci_build_relations = [:deployment, :taggings]
+ preloaded_relations = [:project, :pipeline, :deployment, :taggings]
retry_lock(cancelable_statuses, retries, name: 'ci_pipeline_cancel_running') do |cancelables|
cancelables.find_in_batches do |batch|
- ActiveRecord::Associations::Preloader.new.preload(batch, commit_status_relations)
- ActiveRecord::Associations::Preloader.new.preload(batch.select { |job| job.is_a?(Ci::Build) }, ci_build_relations)
+ Preloaders::CommitStatusPreloader.new(batch).execute(preloaded_relations)
batch.each do |job|
yield(job) if block_given?
@@ -1108,7 +1107,7 @@ module Ci
merge_request.modified_paths
elsif branch_updated?
push_details.modified_paths
- elsif external_pull_request? && ::Feature.enabled?(:ci_modified_paths_of_external_prs, project, default_enabled: :yaml)
+ elsif external_pull_request?
external_pull_request.modified_paths
end
end
@@ -1220,24 +1219,12 @@ module Ci
self.ci_ref = Ci::Ref.ensure_for(self)
end
- # We need `base_and_ancestors` in a specific order to "break" when needed.
- # If we use `find_each`, then the order is broken.
- # rubocop:disable Rails/FindEach
def reset_source_bridge!(current_user)
- if ::Feature.enabled?(:ci_reset_bridge_with_subsequent_jobs, project, default_enabled: :yaml)
- return unless bridge_waiting?
+ return unless bridge_waiting?
- source_bridge.pending!
- Ci::AfterRequeueJobService.new(project, current_user).execute(source_bridge) # rubocop:disable CodeReuse/ServiceClass
- else
- self_and_upstreams.includes(:source_bridge).each do |pipeline|
- break unless pipeline.bridge_waiting?
-
- pipeline.source_bridge.pending!
- end
- end
+ source_bridge.pending!
+ Ci::AfterRequeueJobService.new(project, current_user).execute(source_bridge) # rubocop:disable CodeReuse/ServiceClass
end
- # rubocop:enable Rails/FindEach
# EE-only
def merge_train_pipeline?
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index a0e8886414b..3dca77af051 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -8,7 +8,7 @@ module Ci
alias_attribute :secret_value, :value
- validates :key, uniqueness: { scope: :pipeline_id }
+ validates :key, presence: true
def hook_attrs
{ key: key, value: value }
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 432c3a408a9..4aa232ad26b 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -208,16 +208,18 @@ module Ci
Arel.sql("(#{arel_tag_names_array.to_sql})")
]
- group(*unique_params).pluck('array_agg(ci_runners.id)', *unique_params).map do |values|
- Gitlab::Ci::Matching::RunnerMatcher.new({
- runner_ids: values[0],
- runner_type: values[1],
- public_projects_minutes_cost_factor: values[2],
- private_projects_minutes_cost_factor: values[3],
- run_untagged: values[4],
- access_level: values[5],
- tag_list: values[6]
- })
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339621') do
+ group(*unique_params).pluck('array_agg(ci_runners.id)', *unique_params).map do |values|
+ Gitlab::Ci::Matching::RunnerMatcher.new({
+ runner_ids: values[0],
+ runner_type: values[1],
+ public_projects_minutes_cost_factor: values[2],
+ private_projects_minutes_cost_factor: values[3],
+ run_untagged: values[4],
+ access_level: values[5],
+ tag_list: values[6]
+ })
+ end
end
end
@@ -385,6 +387,12 @@ module Ci
read_attribute(:contacted_at)
end
+ def namespace_ids
+ strong_memoize(:namespace_ids) do
+ runner_namespaces.pluck(:namespace_id).compact
+ end
+ end
+
private
def cleanup_runner_queue
@@ -420,14 +428,18 @@ module Ci
end
def no_projects
- if projects.any?
- errors.add(:runner, 'cannot have projects assigned')
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
+ if projects.any?
+ errors.add(:runner, 'cannot have projects assigned')
+ end
end
end
def no_groups
- if groups.any?
- errors.add(:runner, 'cannot have groups assigned')
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/338659') do
+ if groups.any?
+ errors.add(:runner, 'cannot have groups assigned')
+ end
end
end
diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb
index f78caf710a6..95842d944f9 100644
--- a/app/models/ci/sources/pipeline.rb
+++ b/app/models/ci/sources/pipeline.rb
@@ -4,6 +4,9 @@ module Ci
module Sources
class Pipeline < Ci::ApplicationRecord
include Ci::NamespacedModelName
+ include IgnorableColumns
+
+ ignore_columns 'source_job_id_convert_to_bigint', remove_with: '14.5', remove_after: '2021-11-22'
self.table_name = "ci_sources_pipelines"
diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb
index 9fb8cd024c5..cf6d95fc6df 100644
--- a/app/models/clusters/agent.rb
+++ b/app/models/clusters/agent.rb
@@ -10,6 +10,12 @@ module Clusters
has_many :agent_tokens, class_name: 'Clusters::AgentToken'
has_many :last_used_agent_tokens, -> { order_last_used_at_desc }, class_name: 'Clusters::AgentToken', inverse_of: :agent
+ has_many :group_authorizations, class_name: 'Clusters::Agents::GroupAuthorization'
+ has_many :authorized_groups, class_name: '::Group', through: :group_authorizations, source: :group
+
+ has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization'
+ has_many :authorized_projects, class_name: '::Project', through: :project_authorizations, source: :project
+
scope :ordered_by_name, -> { order(:name) }
scope :with_name, -> (name) { where(name: name) }
diff --git a/app/models/clusters/agents/group_authorization.rb b/app/models/clusters/agents/group_authorization.rb
new file mode 100644
index 00000000000..74c0cec3b7e
--- /dev/null
+++ b/app/models/clusters/agents/group_authorization.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class GroupAuthorization < ApplicationRecord
+ self.table_name = 'agent_group_authorizations'
+
+ belongs_to :agent, class_name: 'Clusters::Agent', optional: false
+ belongs_to :group, class_name: '::Group', optional: false
+
+ validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
+
+ delegate :project, to: :agent
+ end
+ end
+end
diff --git a/app/models/clusters/agents/implicit_authorization.rb b/app/models/clusters/agents/implicit_authorization.rb
new file mode 100644
index 00000000000..967cc686045
--- /dev/null
+++ b/app/models/clusters/agents/implicit_authorization.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class ImplicitAuthorization
+ attr_reader :agent
+
+ delegate :id, to: :agent, prefix: true
+ delegate :project, to: :agent
+
+ def initialize(agent:)
+ @agent = agent
+ end
+
+ def config
+ nil
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/agents/project_authorization.rb b/app/models/clusters/agents/project_authorization.rb
new file mode 100644
index 00000000000..1c71a0a432a
--- /dev/null
+++ b/app/models/clusters/agents/project_authorization.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class ProjectAuthorization < ApplicationRecord
+ self.table_name = 'agent_project_authorizations'
+
+ belongs_to :agent, class_name: 'Clusters::Agent', optional: false
+ belongs_to :project, class_name: '::Project', optional: false
+
+ validates :config, json_schema: { filename: 'cluster_agent_authorization_configuration' }
+ end
+ end
+end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 2fff0a69a26..feac7bbc363 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -148,6 +148,7 @@ module Clusters
scope :with_management_project, -> { where.not(management_project: nil) }
scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) }
+ scope :with_name, -> (name) { where(name: name) }
# with_application_prometheus scope is deprecated, and scheduled for removal
# in %14.0. See https://gitlab.com/groups/gitlab-org/-/epics/4280
diff --git a/app/models/clusters/clusters_hierarchy.rb b/app/models/clusters/clusters_hierarchy.rb
index 162a1a3290d..9435d258d67 100644
--- a/app/models/clusters/clusters_hierarchy.rb
+++ b/app/models/clusters/clusters_hierarchy.rb
@@ -83,7 +83,7 @@ module Clusters
project_id: clusterable.id
}
- model.sanitize_sql_array([Arel.sql(order), values])
+ Arel.sql(model.sanitize_sql_array([Arel.sql(order), values]))
end
def group_clusters_base_query
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 7f5f87e3e36..7ec614b048c 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -137,6 +137,14 @@ module Clusters
kubeclient.patch_ingress(ingress.name, data, namespace)
end
+ def kubeconfig(namespace)
+ to_kubeconfig(
+ url: api_url,
+ namespace: namespace,
+ token: token,
+ ca_pem: ca_pem)
+ end
+
private
def default_namespace(project, environment_name:)
@@ -154,14 +162,6 @@ module Clusters
).execute
end
- def kubeconfig(namespace)
- to_kubeconfig(
- url: api_url,
- namespace: namespace,
- token: token,
- ca_pem: ca_pem)
- end
-
def read_pods(namespace)
kubeclient.get_pods(namespace: namespace).as_json
rescue Kubeclient::ResourceNotFoundError
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index b34d64de101..8cba3d04502 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -56,15 +56,19 @@ class CommitStatus < Ci::ApplicationRecord
scope :for_ref, -> (ref) { where(ref: ref) }
scope :by_name, -> (name) { where(name: name) }
scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) }
- scope :eager_load_pipeline, -> { eager_load(:pipeline, project: { namespace: :route }) }
scope :with_pipeline, -> { joins(:pipeline) }
- scope :updated_at_before, ->(date) { where('updated_at < ?', date) }
+ scope :updated_at_before, ->(date) { where('ci_builds.updated_at < ?', date) }
+ scope :created_at_before, ->(date) { where('ci_builds.created_at < ?', date) }
scope :updated_before, ->(lookback:, timeout:) {
where('(ci_builds.created_at BETWEEN ? AND ?) AND (ci_builds.updated_at BETWEEN ? AND ?)', lookback, timeout, lookback, timeout)
}
+ # The scope applies `pluck` to split the queries. Use with care.
scope :for_project_paths, -> (paths) do
- where(project: Project.where_full_path_in(Array(paths)))
+ # Pluck is used to split this query. Splitting the query is required for database decomposition for `ci_*` tables.
+ # https://docs.gitlab.com/ee/development/database/transaction_guidelines.html#database-decomposition-and-sharding
+ project_ids = Project.where_full_path_in(Array(paths)).pluck(:id)
+ where(project: project_ids)
end
scope :with_preloads, -> do
diff --git a/app/models/compare.rb b/app/models/compare.rb
index 2eaaf98c260..f1b0bf19c11 100644
--- a/app/models/compare.rb
+++ b/app/models/compare.rb
@@ -25,6 +25,16 @@ class Compare
@straight = straight
end
+ # Return a Hash of parameters for passing to a URL helper
+ #
+ # See `namespace_project_compare_url`
+ def to_param
+ {
+ from: @straight ? start_commit_sha : base_commit_sha,
+ to: head_commit_sha
+ }
+ end
+
def cache_key
[@project, :compare, diff_refs.hash]
end
diff --git a/app/models/concerns/approvable_base.rb b/app/models/concerns/approvable_base.rb
index ef7ba7b1089..8240f9bd6ea 100644
--- a/app/models/concerns/approvable_base.rb
+++ b/app/models/concerns/approvable_base.rb
@@ -54,4 +54,8 @@ module ApprovableBase
def can_be_approved_by?(user)
user && !approved_by?(user) && user.can?(:approve_merge_request, self)
end
+
+ def can_be_unapproved_by?(user)
+ user && approved_by?(user) && user.can?(:approve_merge_request, self)
+ end
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index 44d9beff27e..9414d16beef 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -160,39 +160,6 @@ module CacheMarkdownField
# We can only store mentions if the mentionable is a database object
return unless self.is_a?(ApplicationRecord)
- return store_mentions_without_subtransaction! if Feature.enabled?(:store_mentions_without_subtransaction, default_enabled: :yaml)
-
- refs = all_references(self.author)
-
- references = {}
- references[:mentioned_users_ids] = refs.mentioned_user_ids.presence
- references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence
- references[:mentioned_projects_ids] = refs.mentioned_project_ids.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 store_mentions_without_subtransaction!
identifier = user_mention_identifier
# this may happen due to notes polymorphism, so noteable_id may point to a record
diff --git a/app/models/concerns/calloutable.rb b/app/models/concerns/calloutable.rb
new file mode 100644
index 00000000000..8b9cfae6a32
--- /dev/null
+++ b/app/models/concerns/calloutable.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Calloutable
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :user
+
+ validates :user, presence: true
+ end
+
+ def dismissed_after?(dismissed_after)
+ dismissed_at > dismissed_after
+ end
+end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index bdba2d3e251..27a704c1de0 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -33,13 +33,13 @@ module Ci
#
def simple_variables
strong_memoize(:simple_variables) do
- scoped_variables(environment: nil).to_runner_variables
+ scoped_variables(environment: nil)
end
end
def simple_variables_without_dependencies
strong_memoize(:variables_without_dependencies) do
- scoped_variables(environment: nil, dependencies: false).to_runner_variables
+ scoped_variables(environment: nil, dependencies: false)
end
end
diff --git a/app/models/concerns/cron_schedulable.rb b/app/models/concerns/cron_schedulable.rb
index 48605ecc3d7..d5b86db2640 100644
--- a/app/models/concerns/cron_schedulable.rb
+++ b/app/models/concerns/cron_schedulable.rb
@@ -14,12 +14,10 @@ module CronSchedulable
# The `next_run_at` column is set to the actual execution date of worker that
# triggers the schedule. This way, a schedule like `*/1 * * * *` won't be triggered
# in a short interval when the worker runs irregularly by Sidekiq Memory Killer.
- def calculate_next_run_at
- now = Time.zone.now
+ def calculate_next_run_at(start_time = Time.zone.now)
+ ideal_next_run = ideal_next_run_from(start_time)
- ideal_next_run = ideal_next_run_from(now)
-
- if ideal_next_run == cron_worker_next_run_from(now)
+ if ideal_next_run == cron_worker_next_run_from(start_time)
ideal_next_run
else
cron_worker_next_run_from(ideal_next_run)
diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb
index 16dec5fb081..7f46e44697e 100644
--- a/app/models/concerns/enums/ci/commit_status.rb
+++ b/app/models/concerns/enums/ci/commit_status.rb
@@ -26,6 +26,7 @@ module Enums
pipeline_loop_detected: 17,
no_matching_runner: 18, # not used anymore, but cannot be deleted because of old data
trace_size_exceeded: 19,
+ builds_disabled: 20,
insufficient_bridge_permissions: 1_001,
downstream_bridge_project_not_found: 1_002,
invalid_bridge_trigger: 1_003,
diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb
index ed9bce87da1..70d67fc7559 100644
--- a/app/models/concerns/featurable.rb
+++ b/app/models/concerns/featurable.rb
@@ -83,6 +83,10 @@ module Featurable
end
end
+ included do
+ validate :allowed_access_levels
+ end
+
def access_level(feature)
public_send(self.class.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend
end
@@ -94,4 +98,21 @@ module Featurable
def string_access_level(feature)
self.class.str_from_access_level(access_level(feature))
end
+
+ private
+
+ def allowed_access_levels
+ validator = lambda do |field|
+ level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend
+ not_allowed = level > ENABLED
+ self.errors.add(field, "cannot have public visibility level") if not_allowed
+ end
+
+ (self.class.available_features - feature_validation_exclusion).each {|f| validator.call("#{f}_access_level")}
+ end
+
+ # Features that we should exclude from the validation
+ def feature_validation_exclusion
+ []
+ end
end
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index 1b4c590694a..9218ba47d20 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -122,4 +122,8 @@ module HasRepository
def after_repository_change_head
reload_default_branch
end
+
+ def after_change_head_branch_does_not_exist(branch)
+ # No-op (by default)
+ end
end
diff --git a/app/models/concerns/integrations/has_data_fields.rb b/app/models/concerns/integrations/has_data_fields.rb
index e9aaaac8226..1709b56080e 100644
--- a/app/models/concerns/integrations/has_data_fields.rb
+++ b/app/models/concerns/integrations/has_data_fields.rb
@@ -46,6 +46,7 @@ module Integrations
has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::IssueTrackerData'
has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::JiraTrackerData'
has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id, class_name: 'Integrations::OpenProjectTrackerData'
+ has_one :zentao_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :integration_id, class_name: 'Integrations::ZentaoTrackerData'
def data_fields
raise NotImplementedError
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 8d0f8b01d64..5c307158a9a 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -26,6 +26,7 @@ module Issuable
include UpdatedAtFilterable
include ClosedAtFilterable
include VersionedDescription
+ include SortableTitle
TITLE_LENGTH_MAX = 255
TITLE_HTML_LENGTH_MAX = 800
@@ -116,20 +117,6 @@ module Issuable
end
# rubocop:enable GitlabSecurity/SqlInjection
- scope :without_particular_labels, ->(label_names) do
- labels_table = Label.arel_table
- label_links_table = LabelLink.arel_table
- issuables_table = klass.arel_table
- inner_query = label_links_table.project('true')
- .join(labels_table, Arel::Nodes::InnerJoin).on(labels_table[:id].eq(label_links_table[:label_id]))
- .where(label_links_table[:target_type].eq(name)
- .and(label_links_table[:target_id].eq(issuables_table[:id]))
- .and(labels_table[:title].in(label_names)))
- .exists.not
-
- where(inner_query)
- end
-
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) }
scope :join_project, -> { joins(:project) }
@@ -293,6 +280,8 @@ module Issuable
when 'popularity', 'popularity_desc', 'upvotes_desc' then order_upvotes_desc
when 'priority', 'priority_asc' then order_due_date_and_labels_priority(excluded_labels: excluded_labels)
when 'priority_desc' then order_due_date_and_labels_priority('DESC', excluded_labels: excluded_labels)
+ when 'title_asc' then order_title_asc.with_order_id_desc
+ when 'title_desc' then order_title_desc.with_order_id_desc
else order_by(method)
end
diff --git a/app/models/concerns/loose_foreign_key.rb b/app/models/concerns/loose_foreign_key.rb
new file mode 100644
index 00000000000..4e822a04869
--- /dev/null
+++ b/app/models/concerns/loose_foreign_key.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+module LooseForeignKey
+ extend ActiveSupport::Concern
+
+ # This concern adds loose foreign key support to ActiveRecord models.
+ # Loose foreign keys allow delayed processing of associated database records
+ # with similar guarantees than a database foreign key.
+ #
+ # TODO: finalize this later once the async job is in place
+ #
+ # Prerequisites:
+ #
+ # To start using the concern, you'll need to install a database trigger to the parent
+ # table in a standard DB migration (not post-migration).
+ #
+ # > add_loose_foreign_key_support(:projects, :gitlab_main)
+ #
+ # Usage:
+ #
+ # > class Ci::Build < ApplicationRecord
+ # >
+ # > loose_foreign_key :security_scans, :build_id, on_delete: :async_delete, gitlab_schema: :gitlab_main
+ # >
+ # > # associations can be still defined, the dependent options is no longer necessary:
+ # > has_many :security_scans, class_name: 'Security::Scan'
+ # >
+ # > end
+ #
+ # Options for on_delete:
+ #
+ # - :async_delete - deletes the children rows via an asynchronous process.
+ # - :async_nullify - sets the foreign key column to null via an asynchronous process.
+ #
+ # Options for gitlab_schema:
+ #
+ # - :gitlab_ci
+ # - :gitlab_main
+ #
+ # The value can be determined by calling `Model.gitlab_schema` where the Model represents
+ # the model for the child table.
+ #
+ # How it works:
+ #
+ # When adding loose foreign key support to the table, a DELETE trigger is installed
+ # which tracks the record deletions (stores primary key value of the deleted row) in
+ # a database table.
+ #
+ # These deletion records are processed asynchronously and records are cleaned up
+ # according to the loose foreign key definitions described in the model.
+ #
+ # The cleanup happens in batches, which reduces the likelyhood of statement timeouts.
+ #
+ # When all associations related to the deleted record are cleaned up, the record itself
+ # is deleted.
+ included do
+ class_attribute :loose_foreign_key_definitions, default: []
+ end
+
+ class_methods do
+ def loose_foreign_key(to_table, column, options)
+ symbolized_options = options.symbolize_keys
+
+ unless base_class?
+ raise <<~MSG
+ loose_foreign_key can be only used on base classes, inherited classes are not supported.
+ Please define the loose_foreign_key on the #{base_class.name} class.
+ MSG
+ end
+
+ on_delete_options = %i[async_delete async_nullify]
+ gitlab_schema_options = [ApplicationRecord.gitlab_schema, Ci::ApplicationRecord.gitlab_schema]
+
+ unless on_delete_options.include?(symbolized_options[:on_delete]&.to_sym)
+ raise "Invalid on_delete option given: #{symbolized_options[:on_delete]}. Valid options: #{on_delete_options.join(', ')}"
+ end
+
+ unless gitlab_schema_options.include?(symbolized_options[:gitlab_schema]&.to_sym)
+ raise "Invalid gitlab_schema option given: #{symbolized_options[:gitlab_schema]}. Valid options: #{gitlab_schema_options.join(', ')}"
+ end
+
+ definition = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(
+ table_name.to_s,
+ to_table.to_s,
+ {
+ column: column.to_s,
+ on_delete: symbolized_options[:on_delete].to_sym,
+ gitlab_schema: symbolized_options[:gitlab_schema].to_sym
+ }
+ )
+
+ self.loose_foreign_key_definitions += [definition]
+ end
+ end
+end
diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb
index 4df9e32d8ec..a0ea5ac8012 100644
--- a/app/models/concerns/mentionable.rb
+++ b/app/models/concerns/mentionable.rb
@@ -217,17 +217,6 @@ module Mentionable
def user_mention_association
association(:user_mentions).reflection
end
-
- # User mention that is parsed from model description rather then its related notes.
- # 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 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
end
Mentionable.prepend_mod_with('Mentionable')
diff --git a/app/models/concerns/optimized_issuable_label_filter.rb b/app/models/concerns/optimized_issuable_label_filter.rb
deleted file mode 100644
index 19d2ac620f3..00000000000
--- a/app/models/concerns/optimized_issuable_label_filter.rb
+++ /dev/null
@@ -1,121 +0,0 @@
-# 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?
-
- return super if Feature.disabled?(:optimized_issuable_label_filter, default_enabled: :yaml)
-
- target_model = items.model
-
- if params.filter_by_no_label?
- items.where('NOT EXISTS (?)', optimized_any_label_query(target_model))
- elsif params.filter_by_any_label?
- items.where('EXISTS (?)', optimized_any_label_query(target_model))
- else
- issuables_with_selected_labels(items, target_model)
- end
- end
-
- # Taken from IssuableFinder
- def count_by_state
- return super if Feature.disabled?(:optimized_issuable_label_filter, default_enabled: :yaml)
-
- count_params = params.merge(state: nil, sort: nil, force_cte: true)
- finder = self.class.new(current_user, count_params)
-
- state_counts = finder
- .execute
- .reorder(nil)
- .group(:state_id)
- .count
-
- counts = Hash.new(0)
-
- state_counts.each do |key, value|
- counts[count_key(key)] += value
- end
-
- counts[:all] = counts.values.sum
- counts.with_indifferent_access
- end
-
- private
-
- def issuables_with_selected_labels(items, target_model)
- if 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
-
- all_label_ids.each do |label_ids|
- items = items.where('EXISTS (?)', optimized_label_query_by_label_ids(target_model, label_ids))
- end
- else
- params.label_names.each do |label_name|
- items = items.where('EXISTS (?)', optimized_label_query_by_label_name(target_model, label_name))
- end
- end
-
- items
- end
-
- 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
- .map { |labels| labels.map(&:last) }
- end
-
- def root_namespace
- strong_memoize(:root_namespace) do
- (params.project || params.group)&.root_ancestor
- end
- end
-
- def optimized_any_label_query(target_model)
- LabelLink
- .where(target_type: target_model.name)
- .where(LabelLink.arel_table['target_id'].eq(target_model.arel_table['id']))
- .limit(1)
- end
-
- def optimized_label_query_by_label_ids(target_model, label_ids)
- LabelLink
- .where(target_type: target_model.name)
- .where(LabelLink.arel_table['target_id'].eq(target_model.arel_table['id']))
- .where(label_id: label_ids)
- .limit(1)
- end
-
- def optimized_label_query_by_label_name(target_model, label_name)
- LabelLink
- .joins(:label)
- .where(target_type: target_model.name)
- .where(LabelLink.arel_table['target_id'].eq(target_model.arel_table['id']))
- .where(labels: { name: label_name })
- .limit(1)
- end
-end
diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb
index eab5d4c35bb..23d2d00b346 100644
--- a/app/models/concerns/partitioned_table.rb
+++ b/app/models/concerns/partitioned_table.rb
@@ -14,8 +14,6 @@ module PartitionedTable
strategy_class = PARTITIONING_STRATEGIES[strategy.to_sym] || raise(ArgumentError, "Unknown partitioning strategy: #{strategy}")
@partitioning_strategy = strategy_class.new(self, partitioning_key, **kwargs)
-
- Gitlab::Database::Partitioning::PartitionManager.register(self)
end
end
end
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index 75dfed6d58f..c32e499c329 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -135,21 +135,21 @@ module RelativePositioning
before, after = [before, after].sort_by(&:relative_position) if before && after
RelativePositioning.mover.move(self, before, after)
- rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
+ rescue NoSpaceLeft => e
could_not_move(e)
raise e
end
def move_after(before = self)
RelativePositioning.mover.move(self, before, nil)
- rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
+ rescue NoSpaceLeft => e
could_not_move(e)
raise e
end
def move_before(after = self)
RelativePositioning.mover.move(self, nil, after)
- rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
+ rescue NoSpaceLeft => e
could_not_move(e)
raise e
end
@@ -159,9 +159,6 @@ module RelativePositioning
rescue NoSpaceLeft => e
could_not_move(e)
self.relative_position = MAX_POSITION
- rescue ActiveRecord::QueryCanceled => e
- could_not_move(e)
- raise e
end
def move_to_start
@@ -169,9 +166,6 @@ module RelativePositioning
rescue NoSpaceLeft => e
could_not_move(e)
self.relative_position = MIN_POSITION
- rescue ActiveRecord::QueryCanceled => e
- could_not_move(e)
- raise e
end
# This method is used during rebalancing - override it to customise the update
diff --git a/app/models/concerns/sanitizable.rb b/app/models/concerns/sanitizable.rb
new file mode 100644
index 00000000000..05756beb404
--- /dev/null
+++ b/app/models/concerns/sanitizable.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+# == Sanitizable concern
+#
+# This concern adds HTML sanitization and validation to models. The intention is
+# to help prevent XSS attacks in the event of a by-pass in the frontend
+# sanitizer due to a configuration issue or a vulnerability in the sanitizer.
+# This approach is commonly referred to as defense-in-depth.
+#
+# Example:
+#
+# module Dast
+# class Profile < ApplicationRecord
+# include Sanitizable
+#
+# sanitizes! :name, :description
+
+module Sanitizable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def sanitize(input)
+ return unless input
+
+ # We return the input unchanged to avoid escaping pre-escaped HTML fragments.
+ # Please see gitlab-org/gitlab#293634 for an example.
+ return input unless input == CGI.unescapeHTML(input.to_s)
+
+ CGI.unescapeHTML(Sanitize.fragment(input))
+ end
+
+ def sanitizes!(*attrs)
+ instance_eval do
+ before_validation do
+ attrs.each do |attr|
+ input = public_send(attr) # rubocop: disable GitlabSecurity/PublicSend
+
+ public_send("#{attr}=", self.class.sanitize(input)) # rubocop: disable GitlabSecurity/PublicSend
+ end
+ end
+
+ validates_each(*attrs) do |record, attr, input|
+ # We reject pre-escaped HTML fragments as invalid to avoid saving them
+ # to the database.
+ unless input.to_s == CGI.unescapeHTML(input.to_s)
+ record.errors.add(attr, 'cannot contain escaped HTML entities')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/sortable_title.rb b/app/models/concerns/sortable_title.rb
new file mode 100644
index 00000000000..7c5cad17f4c
--- /dev/null
+++ b/app/models/concerns/sortable_title.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module SortableTitle
+ extend ActiveSupport::Concern
+
+ included do
+ scope :order_title_asc, -> { reorder(Arel::Nodes::Ascending.new(arel_table[:title].lower)) }
+ scope :order_title_desc, -> { reorder(Arel::Nodes::Descending.new(arel_table[:title].lower)) }
+ end
+
+ class_methods do
+ def simple_sorts
+ super.merge(
+ {
+ 'title_asc' => -> { order_title_asc },
+ 'title_desc' => -> { order_title_desc }
+ }
+ )
+ end
+ end
+end
diff --git a/app/models/concerns/taggable_queries.rb b/app/models/concerns/taggable_queries.rb
index cba2e93a86d..06799f0a9f4 100644
--- a/app/models/concerns/taggable_queries.rb
+++ b/app/models/concerns/taggable_queries.rb
@@ -3,6 +3,10 @@
module TaggableQueries
extend ActiveSupport::Concern
+ MAX_TAGS_IDS = 50
+
+ TooManyTagsError = Class.new(StandardError)
+
class_methods do
# context is a name `acts_as_taggable context`
def arel_tag_names_array(context = :tags)
@@ -34,4 +38,10 @@ module TaggableQueries
where("EXISTS (?)", matcher)
end
end
+
+ def tags_ids
+ tags.limit(MAX_TAGS_IDS).order('id ASC').pluck(:id).tap do |ids|
+ raise TooManyTagsError if ids.size >= MAX_TAGS_IDS
+ end
+ end
end
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
new file mode 100644
index 00000000000..aaa7e2ae175
--- /dev/null
+++ b/app/models/customer_relations/contact.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class CustomerRelations::Contact < ApplicationRecord
+ include StripAttribute
+
+ self.table_name = "customer_relations_contacts"
+
+ belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'group_id'
+ belongs_to :organization, optional: true
+
+ strip_attributes! :phone, :first_name, :last_name
+
+ enum state: {
+ inactive: 0,
+ active: 1
+ }
+
+ validates :group, presence: true
+ validates :phone, length: { maximum: 32 }
+ validates :first_name, presence: true, length: { maximum: 255 }
+ validates :last_name, presence: true, length: { maximum: 255 }
+ validates :email, length: { maximum: 255 }
+ validates :description, length: { maximum: 1024 }
+ validate :validate_email_format
+
+ private
+
+ def validate_email_format
+ return unless email
+
+ self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
+ end
+end
diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb
index caf1cd68cc5..a18d3ab8148 100644
--- a/app/models/customer_relations/organization.rb
+++ b/app/models/customer_relations/organization.rb
@@ -1,11 +1,13 @@
# frozen_string_literal: true
class CustomerRelations::Organization < ApplicationRecord
+ include StripAttribute
+
self.table_name = "customer_relations_organizations"
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'group_id'
- before_validation :strip_whitespace!
+ strip_attributes! :name
enum state: {
inactive: 0,
@@ -22,10 +24,4 @@ class CustomerRelations::Organization < ApplicationRecord
where(group: group_id)
.where('LOWER(name) = LOWER(?)', name)
end
-
- private
-
- def strip_whitespace!
- name&.strip!
- end
end
diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb
index 3a81112340a..5de6b1cf28f 100644
--- a/app/models/dependency_proxy/blob.rb
+++ b/app/models/dependency_proxy/blob.rb
@@ -8,6 +8,9 @@ class DependencyProxy::Blob < ApplicationRecord
validates :group, presence: true
validates :file, presence: true
validates :file_name, presence: true
+ validates :status, presence: true
+
+ enum status: { default: 0, expired: 1 }
mount_file_store_uploader DependencyProxy::FileUploader
diff --git a/app/models/dependency_proxy/image_ttl_group_policy.rb b/app/models/dependency_proxy/image_ttl_group_policy.rb
new file mode 100644
index 00000000000..5a1b8cb8f1f
--- /dev/null
+++ b/app/models/dependency_proxy/image_ttl_group_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class DependencyProxy::ImageTtlGroupPolicy < ApplicationRecord
+ self.primary_key = :group_id
+
+ belongs_to :group
+
+ validates :group, presence: true
+ validates :enabled, inclusion: { in: [true, false] }
+ validates :ttl, numericality: { greater_than: 0 }, allow_nil: true
+end
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index d613d5708f0..15e5137b50a 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -9,6 +9,9 @@ class DependencyProxy::Manifest < ApplicationRecord
validates :file, presence: true
validates :file_name, presence: true
validates :digest, presence: true
+ validates :status, presence: true
+
+ enum status: { default: 0, expired: 1 }
mount_file_store_uploader DependencyProxy::FileUploader
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index 40c66d5bc4c..363ef0b1c9a 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -3,7 +3,6 @@
class DeployKeysProject < ApplicationRecord
belongs_to :project, inverse_of: :deploy_keys_projects
belongs_to :deploy_key, inverse_of: :deploy_keys_projects
- scope :without_project_deleted, -> { joins(:project).where(projects: { pending_delete: false }) }
scope :in_project, ->(project) { where(project: project) }
scope :with_write_access, -> { where(can_push: true) }
diff --git a/app/models/design_management/action.rb b/app/models/design_management/action.rb
index ecd7973a523..b9df2873a73 100644
--- a/app/models/design_management/action.rb
+++ b/app/models/design_management/action.rb
@@ -17,6 +17,8 @@ module DesignManagement
# we assume sequential ordering.
scope :ordered, -> { order(version_id: :asc) }
+ scope :by_design, -> (design) { where(design: design) }
+ scope :by_event, -> (event) { where(event: event) }
# For each design, only select the most recent action
scope :most_recent, -> do
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index c8a0773cc5b..6ebac6384bc 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -22,7 +22,7 @@ class DiffNote < Note
validate :verify_supported, unless: :importing?
before_validation :set_line_code, if: :on_text?, unless: :importing?
- after_save :keep_around_commits, unless: :importing?
+ after_save :keep_around_commits, unless: -> { importing? || skip_keep_around_commits }
NoteDiffFileCreationError = Class.new(StandardError)
@@ -115,6 +115,20 @@ class DiffNote < Note
position&.multiline?
end
+ def shas
+ [
+ self.original_position.base_sha,
+ self.original_position.start_sha,
+ self.original_position.head_sha
+ ].tap do |a|
+ if self.position != self.original_position
+ a << self.position.base_sha
+ a << self.position.start_sha
+ a << self.position.head_sha
+ end
+ end
+ end
+
private
def enqueue_diff_file_creation_job
@@ -173,18 +187,6 @@ class DiffNote < Note
end
def keep_around_commits
- shas = [
- self.original_position.base_sha,
- self.original_position.start_sha,
- self.original_position.head_sha
- ]
-
- if self.position != self.original_position
- shas << self.position.base_sha
- shas << self.position.start_sha
- shas << self.position.head_sha
- end
-
repository.keep_around(*shas)
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 963249c018a..48522a23068 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -27,11 +27,10 @@ class Environment < ApplicationRecord
has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment
has_one :last_deployment, -> { success.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
- has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus'
- has_one :last_pipeline, through: :last_deployable, source: 'pipeline'
has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment'
- has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus'
- has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline'
+ has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus', disable_joins: -> { ::Feature.enabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml) }
+ has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline', disable_joins: -> { ::Feature.enabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml) }
+
has_one :upcoming_deployment, -> { running.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
@@ -77,6 +76,7 @@ class Environment < ApplicationRecord
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
scope :preload_cluster, -> { preload(last_deployment: :cluster) }
+ scope :preload_project, -> { preload(:project) }
scope :auto_stoppable, -> (limit) { available.where('auto_stop_at < ?', Time.zone.now).limit(limit) }
scope :auto_deletable, -> (limit) { stopped.where('auto_delete_at < ?', Time.zone.now).limit(limit) }
@@ -132,6 +132,10 @@ class Environment < ApplicationRecord
state :available
state :stopped
+ before_transition any => :stopped do |environment|
+ environment.auto_stop_at = nil
+ end
+
after_transition do |environment|
environment.expire_etag_cache
end
@@ -168,33 +172,6 @@ class Environment < ApplicationRecord
end
class << self
- ##
- # This method returns stop actions (jobs) for multiple environments within one
- # query. It's useful to avoid N+1 problem.
- #
- # NOTE: The count of environments should be small~medium (e.g. < 5000)
- def stop_actions
- cte = cte_for_deployments_with_stop_action
- ci_builds = Ci::Build.arel_table
-
- inner_join_stop_actions = ci_builds.join(cte.table).on(
- ci_builds[:project_id].eq(cte.table[:project_id])
- .and(ci_builds[:ref].eq(cte.table[:ref]))
- .and(ci_builds[:name].eq(cte.table[:on_stop]))
- ).join_sources
-
- pipeline_ids = ci_builds.join(cte.table).on(
- ci_builds[:id].eq(cte.table[:deployable_id])
- ).project(:commit_id)
-
- Ci::Build.joins(inner_join_stop_actions)
- .with(cte.to_arel)
- .where(ci_builds[:commit_id].in(pipeline_ids))
- .where(status: Ci::HasStatus::BLOCKED_STATUS)
- .preload_project_and_pipeline_project
- .preload(:user, :metadata, :deployment)
- end
-
def count_by_state
environments_count_by_state = group(:state).count
@@ -202,15 +179,35 @@ class Environment < ApplicationRecord
count_hash[state] = environments_count_by_state[state.to_s] || 0
end
end
+ end
+
+ def last_deployable
+ last_deployment&.deployable
+ end
- private
+ # NOTE: Below assocation overrides is a workaround for issue https://gitlab.com/gitlab-org/gitlab/-/issues/339908
+ # It helps to avoid cross joins with the CI database.
+ # Caveat: It also overrides and losses the default AR caching mechanism.
+ # Read - https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68870#note_677227727
- def cte_for_deployments_with_stop_action
- Gitlab::SQL::CTE.new(:deployments_with_stop_action,
- Deployment.where(environment_id: select(:id))
- .distinct_on_environment
- .stoppable)
- end
+ # NOTE: Association Preloads does not use the overriden definitions below.
+ # Association Preloads when preloading uses the original definitions from the relationships above.
+ # https://github.com/rails/rails/blob/75ac626c4e21129d8296d4206a1960563cc3d4aa/activerecord/lib/active_record/associations/preloader.rb#L158
+ # But after preloading, when they are called it is using the overriden methods below.
+ # So we are checking for `association_cached?(:association_name)` in the overridden methods and calling `super` which inturn fetches the preloaded values.
+
+ # Overriding association
+ def last_visible_deployable
+ return super if association_cached?(:last_visible_deployable) || ::Feature.disabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml)
+
+ last_visible_deployment&.deployable
+ end
+
+ # Overriding association
+ def last_visible_pipeline
+ return super if association_cached?(:last_visible_pipeline) || ::Feature.disabled?(:environment_last_visible_pipeline_disable_joins, default_enabled: :yaml)
+
+ last_visible_deployable&.pipeline
end
def clear_prometheus_reactive_cache!(query_name)
diff --git a/app/models/environment_status.rb b/app/models/environment_status.rb
index 07c0983f239..3be7af2e4bf 100644
--- a/app/models/environment_status.rb
+++ b/app/models/environment_status.rb
@@ -100,11 +100,13 @@ class EnvironmentStatus
def self.build_environments_status(mr, user, pipeline)
return [] unless pipeline
- pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment|
- next unless Ability.allowed?(user, :read_environment, environment)
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/340781') do
+ pipeline.environments_in_self_and_descendants.includes(:project).available.map do |environment|
+ next unless Ability.allowed?(user, :read_environment, environment)
- EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
- end.compact
+ EnvironmentStatus.new(pipeline.project, environment, mr, pipeline.sha)
+ end.compact
+ end
end
private_class_method :build_environments_status
end
diff --git a/app/models/error_tracking/client_key.rb b/app/models/error_tracking/client_key.rb
index 9d12c0ed6f1..8e59f6f9ecb 100644
--- a/app/models/error_tracking/client_key.rb
+++ b/app/models/error_tracking/client_key.rb
@@ -14,9 +14,13 @@ class ErrorTracking::ClientKey < ApplicationRecord
find_by(public_key: key)
end
+ def sentry_dsn
+ @sentry_dsn ||= ErrorTracking::Collector::Dsn.build_url(public_key, project_id)
+ end
+
private
def generate_key
- self.public_key = "glet_#{SecureRandom.hex}"
+ self.public_key ||= "glet_#{SecureRandom.hex}"
end
end
diff --git a/app/models/error_tracking/error.rb b/app/models/error_tracking/error.rb
index 32932c4d045..39ecc487806 100644
--- a/app/models/error_tracking/error.rb
+++ b/app/models/error_tracking/error.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ErrorTracking::Error < ApplicationRecord
+ include Sortable
+
belongs_to :project
has_many :events, class_name: 'ErrorTracking::ErrorEvent'
@@ -22,11 +24,28 @@ class ErrorTracking::Error < ApplicationRecord
def self.report_error(name:, description:, actor:, platform:, timestamp:)
safe_find_or_create_by(
name: name,
- description: description,
actor: actor,
platform: platform
- ) do |error|
- error.update!(last_seen_at: timestamp)
+ ).tap do |error|
+ error.update!(
+ # Description can contain object id, so it can't be
+ # used as a group criteria for similar errors.
+ description: description,
+ last_seen_at: timestamp
+ )
+ end
+ end
+
+ def self.sort_by_attribute(method)
+ case method.to_s
+ when 'last_seen'
+ order(last_seen_at: :desc)
+ when 'first_seen'
+ order(first_seen_at: :desc)
+ when 'frequency'
+ order(events_count: :desc)
+ else
+ order_id_desc
end
end
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index c5a77427588..dd5ce9f7387 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -51,7 +51,7 @@ module ErrorTracking
end
def integrated_client?
- integrated && ::Feature.enabled?(:integrated_error_tracking, project)
+ integrated
end
def api_url=(value)
diff --git a/app/models/event.rb b/app/models/event.rb
index f6174589a84..d6588699d27 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -9,6 +9,9 @@ class Event < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include UsageStatistics
include ShaAttribute
+ include IgnorableColumns
+
+ ignore_columns :id_convert_to_bigint, remove_with: '14.5', remove_after: '2021-10-22'
default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope
diff --git a/app/models/group.rb b/app/models/group.rb
index f6b45a755e4..437c750afa6 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -18,6 +18,10 @@ class Group < Namespace
include EachBatch
include BulkMemberAccessLoad
+ def self.sti_name
+ 'Group'
+ end
+
has_many :all_group_members, -> { where(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :group_members, -> { where(requested_at: nil).where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
alias_method :members, :group_members
@@ -74,13 +78,16 @@ class Group < Namespace
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :dependency_proxy_setting, class_name: 'DependencyProxy::GroupSetting'
+ has_one :dependency_proxy_image_ttl_policy, class_name: 'DependencyProxy::ImageTtlGroupPolicy'
has_many :dependency_proxy_blobs, class_name: 'DependencyProxy::Blob'
has_many :dependency_proxy_manifests, class_name: 'DependencyProxy::Manifest'
# debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads
has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- delegate :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, to: :namespace_settings
+ has_many :group_callouts, class_name: 'Users::GroupCallout', foreign_key: :group_id
+
+ delegate :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, :setup_for_company, :jobs_to_be_done, to: :namespace_settings
accepts_nested_attributes_for :variables, allow_destroy: true
@@ -260,6 +267,15 @@ class Group < Namespace
Gitlab::UrlBuilder.build(self, only_path: only_path)
end
+ def dependency_proxy_image_prefix
+ # The namespace path can include uppercase letters, which
+ # Docker doesn't allow. The proxy expects it to be downcased.
+ url = "#{web_url.downcase}#{DependencyProxy::URL_SUFFIX}"
+
+ # Docker images do not include the protocol
+ url.partition('//').last
+ end
+
def human_name
full_name
end
@@ -296,7 +312,7 @@ class Group < Namespace
end
def add_users(users, access_level, current_user: nil, expires_at: nil)
- Members::Groups::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
+ Members::Groups::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
self,
users,
access_level,
@@ -642,6 +658,10 @@ class Group < Namespace
members.owners.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT)
end
+ def membership_locked?
+ false # to support project and group calling this as 'source'
+ end
+
def supports_events?
false
end
@@ -734,6 +754,22 @@ class Group < Namespace
Timelog.in_group(self)
end
+ def cached_issues_state_count_enabled?
+ Feature.enabled?(:cached_issues_state_count, self, default_enabled: :yaml)
+ end
+
+ def organizations
+ ::CustomerRelations::Organization.where(group_id: self.id)
+ end
+
+ def contacts
+ ::CustomerRelations::Contact.where(group_id: self.id)
+ end
+
+ def dependency_proxy_image_ttl_policy
+ super || build_dependency_proxy_image_ttl_policy
+ end
+
private
def max_member_access(user_ids)
@@ -822,9 +858,15 @@ class Group < Namespace
end
def self.groups_including_descendants_by(group_ids)
- Gitlab::ObjectHierarchy
- .new(Group.where(id: group_ids))
+ groups = Group.where(id: group_ids)
+
+ if Feature.enabled?(:linear_group_including_descendants_by, default_enabled: :yaml)
+ groups.self_and_descendants
+ else
+ Gitlab::ObjectHierarchy
+ .new(groups)
.base_and_descendants
+ end
end
def disable_shared_runners!
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 9a78fe3971c..cb5c1ac48cd 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -80,6 +80,8 @@ class WebHook < ApplicationRecord
end
def backoff!
+ return if backoff_count >= MAX_FAILURES && disabled_until && disabled_until > Time.current
+
assign_attributes(disabled_until: next_backoff.from_now, backoff_count: backoff_count.succ.clamp(0, MAX_FAILURES))
save(validate: false)
end
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 09a60e9dd10..9565dae08b5 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -13,7 +13,7 @@ class InstanceConfiguration
{ ssh_algorithms_hashes: ssh_algorithms_hashes,
host: host,
gitlab_pages: gitlab_pages,
- gitlab_ci: gitlab_ci,
+ size_limits: size_limits,
package_file_size_limits: package_file_size_limits,
rate_limits: rate_limits }.deep_symbolize_keys
end
@@ -38,11 +38,16 @@ class InstanceConfiguration
rescue Resolv::ResolvError
end
- def gitlab_ci
- Settings.gitlab_ci
- .to_h
- .merge(artifacts_max_size: { value: Gitlab::CurrentSettings.max_artifacts_size.megabytes,
- default: 100.megabytes })
+ def size_limits
+ {
+ max_attachment_size: application_settings[:max_attachment_size].megabytes,
+ receive_max_input_size: application_settings[:receive_max_input_size]&.megabytes,
+ max_import_size: application_settings[:max_import_size] > 0 ? application_settings[:max_import_size].megabytes : nil,
+ diff_max_patch_bytes: application_settings[:diff_max_patch_bytes].bytes,
+ max_artifacts_size: application_settings[:max_artifacts_size].megabytes,
+ max_pages_size: application_settings[:max_pages_size] > 0 ? application_settings[:max_pages_size].megabytes : nil,
+ snippet_size_limit: application_settings[:snippet_size_limit]&.bytes
+ }
end
def package_file_size_limits
diff --git a/app/models/integration.rb b/app/models/integration.rb
index a9c865569d0..158764bb783 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -274,7 +274,7 @@ class Integration < ApplicationRecord
end
def self.closest_group_integration(type, scope)
- group_ids = scope.ancestors(hierarchy_order: :asc).select(:id)
+ group_ids = scope.ancestors(hierarchy_order: :asc).reselect(:id)
array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
where(type: type, group_id: group_ids, inherit_from_id: nil)
@@ -357,6 +357,10 @@ class Integration < ApplicationRecord
[]
end
+ def password_fields
+ fields.select { |f| f[:type] == 'password' }.pluck(:name)
+ end
+
# Expose a list of fields in the JSON endpoint.
#
# This list is used in `Integration#as_json(only: json_fields)`.
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index 5eae8bce92a..c6335782b5e 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -253,3 +253,5 @@ module Integrations
end
end
end
+
+Integrations::BaseChatNotification.prepend_mod_with('Integrations::BaseChatNotification')
diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb
index 6422f6bddab..72e0ca22ac2 100644
--- a/app/models/integrations/datadog.rb
+++ b/app/models/integrations/datadog.rb
@@ -7,7 +7,7 @@ module Integrations
extend Gitlab::Utils::Override
DEFAULT_DOMAIN = 'datadoghq.com'
- URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_domain}/api/v2/webhook'
+ URL_TEMPLATE = 'https://webhook-intake.%{datadog_domain}/api/v2/webhook'
URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_DOMAIN}/account_management/api-app-keys/"
SUPPORTED_EVENTS = %w[
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index 54cb823d606..5746343c31c 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -76,7 +76,7 @@ module Integrations
name: 'google_iap_audience_client_id',
title: 'Google IAP Audience Client ID',
placeholder: s_('PrometheusService|IAP_CLIENT_ID.apps.googleusercontent.com'),
- help: s_('PrometheusService|PrometheusService|The ID of the IAP-secured resource.'),
+ help: s_('PrometheusService|The ID of the IAP-secured resource.'),
autocomplete: 'off',
required: false
},
diff --git a/app/models/integrations/slack_slash_commands.rb b/app/models/integrations/slack_slash_commands.rb
index ff1f806df45..72e3c4a8cbc 100644
--- a/app/models/integrations/slack_slash_commands.rb
+++ b/app/models/integrations/slack_slash_commands.rb
@@ -9,7 +9,7 @@ module Integrations
end
def description
- "Perform common operations in Slack"
+ "Perform common operations in Slack."
end
def self.to_param
diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb
new file mode 100644
index 00000000000..68c02f54c61
--- /dev/null
+++ b/app/models/integrations/zentao.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Zentao < Integration
+ data_field :url, :api_url, :api_token, :zentao_product_xid
+
+ validates :url, public_url: true, presence: true, if: :activated?
+ validates :api_url, public_url: true, allow_blank: true
+ validates :api_token, presence: true, if: :activated?
+ validates :zentao_product_xid, presence: true, if: :activated?
+
+ def data_fields
+ zentao_tracker_data || self.build_zentao_tracker_data
+ end
+
+ def title
+ self.class.name.demodulize
+ end
+
+ def description
+ s_("ZentaoIntegration|Use Zentao as this project's issue tracker.")
+ end
+
+ def self.to_param
+ name.demodulize.downcase
+ end
+
+ def test(*_args)
+ client.ping
+ end
+
+ def self.supported_events
+ %w()
+ end
+
+ def self.supported_event_actions
+ %w()
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'url',
+ title: s_('ZentaoIntegration|Zentao Web URL'),
+ placeholder: 'https://www.zentao.net',
+ help: s_('ZentaoIntegration|Base URL of the Zentao instance.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'api_url',
+ title: s_('ZentaoIntegration|Zentao API URL (optional)'),
+ help: s_('ZentaoIntegration|If different from Web URL.')
+ },
+ {
+ type: 'password',
+ name: 'api_token',
+ title: s_('ZentaoIntegration|Zentao API token'),
+ non_empty_password_title: s_('ZentaoIntegration|Enter API token'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'zentao_product_xid',
+ title: s_('ZentaoIntegration|Zentao Product ID'),
+ required: true
+ }
+ ]
+ end
+
+ private
+
+ def client
+ @client ||= ::Gitlab::Zentao::Client.new(self)
+ end
+ end
+end
diff --git a/app/models/integrations/zentao_tracker_data.rb b/app/models/integrations/zentao_tracker_data.rb
new file mode 100644
index 00000000000..468e4e5d7d7
--- /dev/null
+++ b/app/models/integrations/zentao_tracker_data.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Integrations
+ class ZentaoTrackerData < ApplicationRecord
+ belongs_to :integration, inverse_of: :zentao_tracker_data, foreign_key: :integration_id
+ delegate :activated?, to: :integration
+ validates :integration, presence: true
+
+ scope :encryption_options, -> do
+ {
+ key: Settings.attr_encrypted_db_key_base_32,
+ encode: true,
+ mode: :per_attribute_iv,
+ algorithm: 'aes-256-gcm'
+ }
+ end
+
+ attr_encrypted :url, encryption_options
+ attr_encrypted :api_url, encryption_options
+ attr_encrypted :zentao_product_xid, encryption_options
+ attr_encrypted :api_token, encryption_options
+ end
+end
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index a54de3c82d1..10d24ab50b2 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -4,7 +4,7 @@
# generated for a given scope and usage.
#
# The monotone sequence may be broken if an ID is explicitly provided
-# to `.track_greatest_and_save!` or `#track_greatest`.
+# to `#track_greatest`.
#
# For example, issues use their project to scope internal ids:
# In that sense, scope is "project" and usage is "issues".
@@ -29,32 +29,6 @@ class InternalId < ApplicationRecord
where(**scope, usage: usage)
end
- # Increments #last_value and saves the record
- #
- # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
- # As such, the increment is atomic and safe to be called concurrently.
- def increment_and_save!
- update_and_save { self.last_value = (last_value || 0) + 1 }
- end
-
- # Increments #last_value with new_value if it is greater than the current,
- # and saves the record
- #
- # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
- # As such, the increment is atomic and safe to be called concurrently.
- def track_greatest_and_save!(new_value)
- update_and_save { self.last_value = [last_value || 0, new_value].max }
- end
-
- private
-
- def update_and_save(&block)
- lock!
- yield
- save!
- last_value
- end
-
class << self
def track_greatest(subject, scope, usage, new_value, init)
build_generator(subject, scope, usage, init).track_greatest(new_value)
@@ -99,132 +73,7 @@ class InternalId < ApplicationRecord
private
def build_generator(subject, scope, usage, init = nil)
- if Feature.enabled?(:generate_iids_without_explicit_locking)
- ImplicitlyLockingInternalIdGenerator.new(subject, scope, usage, init)
- else
- InternalIdGenerator.new(subject, scope, usage, init)
- end
- end
- end
-
- class InternalIdGenerator
- # Generate next internal id for a given scope and usage.
- #
- # For currently supported usages, see #usage enum.
- #
- # The method implements a locking scheme that has the following properties:
- # 1) Generated sequence of internal ids is unique per (scope and usage)
- # 2) The method is thread-safe and may be used in concurrent threads/processes.
- # 3) The generated sequence is gapless.
- # 4) In the absence of a record in the internal_ids table, one will be created
- # and last_value will be calculated on the fly.
- #
- # subject: The instance or class we're generating an internal id for.
- # scope: Attributes that define the scope for id generation.
- # Valid keys are `project/project_id` and `namespace/namespace_id`.
- # usage: Symbol to define the usage of the internal id, see InternalId.usages
- # init: Proc that accepts the subject and the scope and returns Integer|NilClass
- attr_reader :subject, :scope, :scope_attrs, :usage, :init
-
- def initialize(subject, scope, usage, init = nil)
- @subject = subject
- @scope = scope
- @usage = usage
- @init = init
-
- raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
-
- unless InternalId.usages.has_key?(usage.to_s)
- raise ArgumentError, "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages"
- end
- end
-
- # Generates next internal id and returns it
- # init: Block that gets called to initialize InternalId record if not present
- # Make sure to not throw exceptions in the absence of records (if this is expected).
- def generate
- InternalId.internal_id_transactions_increment(operation: :generate, usage: usage)
-
- subject.transaction do
- # Create a record in internal_ids if one does not yet exist
- # and increment its last value
- #
- # Note this will acquire a ROW SHARE lock on the InternalId record
- record.increment_and_save!
- end
- end
-
- # Reset tries to rewind to `value-1`. This will only succeed,
- # if `value` stored in database is equal to `last_value`.
- # value: The expected last_value to decrement
- def reset(value)
- return false unless value
-
- InternalId.internal_id_transactions_increment(operation: :reset, usage: usage)
-
- updated =
- InternalId
- .where(**scope, usage: usage_value)
- .where(last_value: value)
- .update_all('last_value = last_value - 1')
-
- updated > 0
- end
-
- # Create a record in internal_ids if one does not yet exist
- # and set its new_value if it is higher than the current last_value
- #
- # Note this will acquire a ROW SHARE lock on the InternalId record
-
- def track_greatest(new_value)
- InternalId.internal_id_transactions_increment(operation: :track_greatest, usage: usage)
-
- subject.transaction do
- record.track_greatest_and_save!(new_value)
- end
- end
-
- def record
- @record ||= (lookup || create_record)
- end
-
- def with_lock(&block)
- InternalId.internal_id_transactions_increment(operation: :with_lock, usage: usage)
-
- record.with_lock(&block)
- end
-
- private
-
- # Retrieve InternalId record for (project, usage) combination, if it exists
- def lookup
- InternalId.find_by(**scope, usage: usage_value)
- end
-
- def usage_value
- @usage_value ||= InternalId.usages[usage.to_s]
- end
-
- # Create InternalId record for (scope, usage) combination, if it doesn't exist
- #
- # We blindly insert without synchronization. If another process
- # was faster in doing this, we'll realize once we hit the unique key constraint
- # violation. We can safely roll-back the nested transaction and perform
- # a lookup instead to retrieve the record.
- def create_record
- raise ArgumentError, 'Cannot initialize without init!' unless init
-
- instance = subject.is_a?(::Class) ? nil : subject
-
- subject.transaction(requires_new: true) do
- InternalId.create!(
- **scope,
- usage: usage_value,
- last_value: init.call(instance, scope) || 0
- )
- end
- rescue ActiveRecord::RecordNotUnique
- lookup
+ ImplicitlyLockingInternalIdGenerator.new(subject, scope, usage, init)
end
end
@@ -247,6 +96,8 @@ class InternalId < ApplicationRecord
# init: Proc that accepts the subject and the scope and returns Integer|NilClass
attr_reader :subject, :scope, :scope_attrs, :usage, :init
+ RecordAlreadyExists = Class.new(StandardError)
+
def initialize(subject, scope, usage, init = nil)
@subject = subject
@scope = scope
@@ -270,10 +121,8 @@ class InternalId < ApplicationRecord
return next_iid if next_iid
- create_record!(subject, scope, usage, init) do |iid|
- iid.last_value += 1
- end
- rescue ActiveRecord::RecordNotUnique
+ create_record!(subject, scope, usage, initial_value(subject, scope) + 1)
+ rescue RecordAlreadyExists
retry
end
@@ -302,10 +151,8 @@ class InternalId < ApplicationRecord
next_iid = update_record!(subject, scope, usage, function)
return next_iid if next_iid
- create_record!(subject, scope, usage, init) do |object|
- object.last_value = [object.last_value, new_value].max
- end
- rescue ActiveRecord::RecordNotUnique
+ create_record!(subject, scope, usage, [initial_value(subject, scope), new_value].max)
+ rescue RecordAlreadyExists
retry
end
@@ -317,27 +164,45 @@ class InternalId < ApplicationRecord
stmt.set(arel_table[:last_value] => new_value)
stmt.wheres = InternalId.filter_by(scope, usage).arel.constraints
- ActiveRecord::Base.connection.insert(stmt, 'Update InternalId', 'last_value') # rubocop: disable Database/MultipleDatabases
+ InternalId.connection.insert(stmt, 'Update InternalId', 'last_value')
end
- def create_record!(subject, scope, usage, init)
- raise ArgumentError, 'Cannot initialize without init!' unless init
+ def create_record!(subject, scope, usage, value)
+ scope[:project].save! if scope[:project] && !scope[:project].persisted?
+ scope[:namespace].save! if scope[:namespace] && !scope[:namespace].persisted?
- instance = subject.is_a?(::Class) ? nil : subject
+ attributes = {
+ project_id: scope[:project]&.id || scope[:project_id],
+ namespace_id: scope[:namespace]&.id || scope[:namespace_id],
+ usage: usage_value,
+ last_value: value
+ }
- subject.transaction(requires_new: true) do
- last_value = init.call(instance, scope) || 0
+ result = InternalId.insert(attributes)
- internal_id = InternalId.create!(**scope, usage: usage, last_value: last_value) do |subject|
- yield subject if block_given?
- end
+ raise RecordAlreadyExists if result.empty?
- internal_id.last_value
- end
+ value
end
def arel_table
InternalId.arel_table
end
+
+ def initial_value(subject, scope)
+ raise ArgumentError, 'Cannot initialize without init!' unless init
+
+ # `init` computes the maximum based on actual records. We use the
+ # primary to make sure we have up to date results
+ Gitlab::Database::LoadBalancing::Session.current.use_primary do
+ instance = subject.is_a?(::Class) ? nil : subject
+
+ init.call(instance, scope) || 0
+ end
+ end
+
+ def usage_value
+ @usage_value ||= InternalId.usages[usage.to_s]
+ end
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 48e3fdd51e9..e0b0c352c22 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -128,13 +128,15 @@ class Issue < ApplicationRecord
}
scope :with_issue_type, ->(types) { where(issue_type: types) }
- scope :public_only, -> { where(confidential: false) }
+ scope :public_only, -> {
+ without_hidden.where(confidential: false)
+ }
+
scope :confidential_only, -> { where(confidential: true) }
scope :without_hidden, -> {
if Feature.enabled?(:ban_user_feature_flag)
- where(id: joins('LEFT JOIN banned_users ON banned_users.user_id = issues.author_id WHERE banned_users.user_id IS NULL')
- .select('issues.id'))
+ where('NOT EXISTS (?)', Users::BannedUser.select(1).where('issues.author_id = banned_users.user_id'))
else
all
end
@@ -323,6 +325,13 @@ class Issue < ApplicationRecord
)
end
+ def self.column_order_id_asc
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ order_expression: arel_table[:id].asc
+ )
+ end
+
def self.to_branch_name(*args)
branch_name = args.map(&:to_s).each_with_index.map do |arg, i|
arg.parameterize(preserve_case: i == 0).presence
@@ -584,15 +593,9 @@ class Issue < ApplicationRecord
confidential_changed?(from: true, to: false)
end
- # Ensure that the metrics association is safely created and respecting the unique constraint on issue_id
override :ensure_metrics
def ensure_metrics
- if !association(:metrics).loaded? || metrics.blank?
- metrics_record = Issue::Metrics.safe_find_or_create_by(issue: self)
- self.metrics = metrics_record
- end
-
- metrics.record!
+ Issue::Metrics.record!(self)
end
def record_create_action
diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb
index 86523bbd023..25afd9bf58d 100644
--- a/app/models/issue/metrics.rb
+++ b/app/models/issue/metrics.rb
@@ -9,25 +9,30 @@ class Issue::Metrics < ApplicationRecord
.or(where(arel_table['first_mentioned_in_commit_at'].gteq(timestamp)))
}
- def record!
- if issue.milestone_id.present? && self.first_associated_with_milestone_at.blank?
- self.first_associated_with_milestone_at = Time.current
+ class << self
+ def record!(issue)
+ now = connection.quote(Time.current)
+ first_associated_with_milestone_at = issue.milestone_id.present? ? now : 'NULL'
+ first_added_to_board_at = issue_assigned_to_list_label?(issue) ? now : 'NULL'
+
+ sql = <<~SQL
+ INSERT INTO #{self.table_name} (issue_id, first_associated_with_milestone_at, first_added_to_board_at, created_at, updated_at)
+ VALUES (#{issue.id}, #{first_associated_with_milestone_at}, #{first_added_to_board_at}, NOW(), NOW())
+ ON CONFLICT (issue_id)
+ DO UPDATE SET
+ first_associated_with_milestone_at = LEAST(#{self.table_name}.first_associated_with_milestone_at, EXCLUDED.first_associated_with_milestone_at),
+ first_added_to_board_at = LEAST(#{self.table_name}.first_added_to_board_at, EXCLUDED.first_added_to_board_at),
+ updated_at = NOW()
+ RETURNING id
+ SQL
+
+ connection.execute(sql)
end
- if issue_assigned_to_list_label? && self.first_added_to_board_at.blank?
- self.first_added_to_board_at = Time.current
- end
-
- self.save
- end
+ private
- private
-
- def issue_assigned_to_list_label?
- # Avoid another DB lookup when issue.labels are empty by adding a guard clause here
- # We can't use issue.labels.empty? because that will cause a `Label Exists?` DB lookup
- return false if issue.labels.length == 0 # rubocop:disable Style/ZeroLengthPredicate
-
- issue.labels.includes(:lists).any? { |label| label.lists.present? }
+ def issue_assigned_to_list_label?(issue)
+ issue.labels.joins(:lists).exists?
+ end
end
end
diff --git a/app/models/loose_foreign_keys.rb b/app/models/loose_foreign_keys.rb
new file mode 100644
index 00000000000..0f45c0b5568
--- /dev/null
+++ b/app/models/loose_foreign_keys.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module LooseForeignKeys
+ def self.table_name_prefix
+ 'loose_foreign_keys_'
+ end
+end
diff --git a/app/models/loose_foreign_keys/deleted_record.rb b/app/models/loose_foreign_keys/deleted_record.rb
new file mode 100644
index 00000000000..a39d88b2e49
--- /dev/null
+++ b/app/models/loose_foreign_keys/deleted_record.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class LooseForeignKeys::DeletedRecord < ApplicationRecord
+ extend SuppressCompositePrimaryKeyWarning
+ include PartitionedTable
+
+ partitioned_by :created_at, strategy: :monthly, retain_for: 3.months, retain_non_empty_partitions: true
+
+ scope :ordered_by_primary_keys, -> { order(:created_at, :deleted_table_name, :deleted_table_primary_key_value) }
+
+ def self.load_batch(batch_size)
+ ordered_by_primary_keys
+ .limit(batch_size)
+ .to_a
+ end
+
+ # Because the table has composite primary keys, the delete_all or delete methods are not going to work.
+ # This method implements deletion that benefits from the primary key index, example:
+ #
+ # > DELETE
+ # > FROM "loose_foreign_keys_deleted_records"
+ # > WHERE (created_at,
+ # > deleted_table_name,
+ # > deleted_table_primary_key_value) IN
+ # > (SELECT created_at::TIMESTAMP WITH TIME ZONE,
+ # > deleted_table_name,
+ # > deleted_table_primary_key_value
+ # > FROM (VALUES (LIST_OF_VALUES)) AS primary_key_values (created_at, deleted_table_name, deleted_table_primary_key_value))
+ def self.delete_records(records)
+ values = records.pluck(:created_at, :deleted_table_name, :deleted_table_primary_key_value)
+
+ primary_keys = connection.primary_keys(table_name).join(', ')
+
+ primary_keys_with_type_cast = [
+ Arel.sql('created_at::timestamp with time zone'),
+ Arel.sql('deleted_table_name'),
+ Arel.sql('deleted_table_primary_key_value')
+ ]
+
+ value_list = Arel::Nodes::ValuesList.new(values)
+
+ # (SELECT primary keys FROM VALUES)
+ inner_query = Arel::SelectManager.new
+ inner_query.from("#{Arel::Nodes::Grouping.new([value_list]).as('primary_key_values').to_sql} (#{primary_keys})")
+ inner_query.projections = primary_keys_with_type_cast
+
+ where(Arel::Nodes::Grouping.new([Arel.sql(primary_keys)]).in(inner_query)).delete_all
+ end
+end
diff --git a/app/models/member.rb b/app/models/member.rb
index 397e60be3a8..beb4c05f2a6 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -147,7 +147,6 @@ class Member < ApplicationRecord
scope :owners, -> { active.where(access_level: OWNER) }
scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
scope :with_user, -> (user) { where(user: user) }
- scope :with_user_by_email, -> (email) { left_join_users.where(users: { email: email } ) }
scope :preload_user_and_notification_settings, -> { preload(user: :notification_settings) }
@@ -278,12 +277,14 @@ class Member < ApplicationRecord
def accept_invite!(new_user)
return false unless invite?
+ return false unless new_user
+
+ self.user = new_user
+ return false unless self.user.save
self.invite_token = nil
self.invite_accepted_at = Time.current.utc
- self.user = new_user
-
saved = self.save
after_accept_invite if saved
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index b45c0b6a0cc..72cb831cc88 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -44,7 +44,7 @@ class ProjectMember < Member
project_ids.each do |project_id|
project = Project.find(project_id)
- Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
+ Members::Projects::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
project,
users,
access_level,
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index a090ac87cc9..db49ec6f412 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -615,8 +615,8 @@ class MergeRequest < ApplicationRecord
context_commits.count
end
- def commits(limit: nil)
- return merge_request_diff.commits(limit: limit) if merge_request_diff.persisted?
+ def commits(limit: nil, load_from_gitaly: false)
+ return merge_request_diff.commits(limit: limit, load_from_gitaly: load_from_gitaly) if merge_request_diff.persisted?
commits_arr = if compare_commits
reversed_commits = compare_commits.reverse
@@ -628,8 +628,8 @@ class MergeRequest < ApplicationRecord
CommitCollection.new(source_project, commits_arr, source_branch)
end
- def recent_commits
- commits(limit: MergeRequestDiff::COMMITS_SAFE_SIZE)
+ def recent_commits(load_from_gitaly: false)
+ commits(limit: MergeRequestDiff::COMMITS_SAFE_SIZE, load_from_gitaly: load_from_gitaly)
end
def commits_count
@@ -1349,7 +1349,9 @@ class MergeRequest < ApplicationRecord
def has_ci?
return false if has_no_commits?
- !!(head_pipeline_id || all_pipelines.any? || source_project&.ci_integration)
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do
+ !!(head_pipeline_id || all_pipelines.any? || source_project&.ci_integration)
+ end
end
def branch_missing?
@@ -1835,15 +1837,10 @@ class MergeRequest < ApplicationRecord
Ability.allowed?(user, :push_code, source_project)
end
- def squash_in_progress?
- # The source project can be deleted
- return false unless source_project
-
- source_project.repository.squash_in_progress?(id)
- end
-
def find_actual_head_pipeline
- all_pipelines.for_sha_or_source_sha(diff_head_sha).first
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336891') do
+ all_pipelines.for_sha_or_source_sha(diff_head_sha).first
+ end
end
def etag_caching_enabled?
@@ -1860,25 +1857,29 @@ class MergeRequest < ApplicationRecord
override :ensure_metrics
def ensure_metrics
- # Backward compatibility: some merge request metrics records will not have target_project_id filled in.
- # In that case the first `safe_find_or_create_by` will return false.
- # The second finder call will be eliminated in https://gitlab.com/gitlab-org/gitlab/-/issues/233507
- metrics_record = MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id, target_project_id: target_project_id) || MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id)
-
- metrics_record.tap do |metrics_record|
- # Make sure we refresh the loaded association object with the newly created/loaded item.
- # This is needed in order to have the exact functionality than before.
- #
- # Example:
- #
- # merge_request.metrics.destroy
- # merge_request.ensure_metrics
- # merge_request.metrics # should return the metrics record and not nil
- # merge_request.metrics.merge_request # should return the same MR record
-
- metrics_record.target_project_id = target_project_id
- metrics_record.association(:merge_request).target = self
- association(:metrics).target = metrics_record
+ if Feature.enabled?(:use_upsert_query_for_mr_metrics)
+ MergeRequest::Metrics.record!(self)
+ else
+ # Backward compatibility: some merge request metrics records will not have target_project_id filled in.
+ # In that case the first `safe_find_or_create_by` will return false.
+ # The second finder call will be eliminated in https://gitlab.com/gitlab-org/gitlab/-/issues/233507
+ metrics_record = MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id, target_project_id: target_project_id) || MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id)
+
+ metrics_record.tap do |metrics_record|
+ # Make sure we refresh the loaded association object with the newly created/loaded item.
+ # This is needed in order to have the exact functionality than before.
+ #
+ # Example:
+ #
+ # merge_request.metrics.destroy
+ # merge_request.ensure_metrics
+ # merge_request.metrics # should return the metrics record and not nil
+ # merge_request.metrics.merge_request # should return the same MR record
+
+ metrics_record.target_project_id = target_project_id
+ metrics_record.association(:merge_request).target = self
+ association(:metrics).target = metrics_record
+ end
end
end
@@ -1917,6 +1918,20 @@ class MergeRequest < ApplicationRecord
end
end
+ def lazy_upvotes_count
+ BatchLoader.for(id).batch(default_value: 0) do |ids, loader|
+ counts = AwardEmoji
+ .where(awardable_id: ids)
+ .upvotes
+ .group(:awardable_id)
+ .count
+
+ counts.each do |id, count|
+ loader.call(id, count)
+ end
+ end
+ end
+
private
def set_draft_status
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index b9460afa8e7..b984228eb13 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -14,8 +14,23 @@ class MergeRequest::Metrics < ApplicationRecord
scope :with_valid_time_to_merge, -> { where(arel_table[:merged_at].gt(arel_table[:created_at])) }
scope :by_target_project, ->(project) { where(target_project_id: project) }
- def self.time_to_merge_expression
- Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))')
+ class << self
+ def time_to_merge_expression
+ Arel.sql('EXTRACT(epoch FROM SUM(AGE(merge_request_metrics.merged_at, merge_request_metrics.created_at)))')
+ end
+
+ def record!(mr)
+ sql = <<~SQL
+ INSERT INTO #{self.table_name} (merge_request_id, target_project_id, updated_at, created_at)
+ VALUES (#{mr.id}, #{mr.target_project_id}, NOW(), NOW())
+ ON CONFLICT (merge_request_id)
+ DO UPDATE SET
+ target_project_id = EXCLUDED.target_project_id,
+ updated_at = NOW()
+ SQL
+
+ connection.execute(sql)
+ end
end
private
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index bea75927b2c..d2b3ca753b1 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -288,9 +288,9 @@ class MergeRequestDiff < ApplicationRecord
end
end
- def commits(limit: nil)
- strong_memoize(:"commits_#{limit || 'all'}") do
- load_commits(limit: limit)
+ def commits(limit: nil, load_from_gitaly: false)
+ strong_memoize(:"commits_#{limit || 'all'}_#{load_from_gitaly}") do
+ load_commits(limit: limit, load_from_gitaly: load_from_gitaly)
end
end
@@ -700,9 +700,14 @@ class MergeRequestDiff < ApplicationRecord
end
end
- def load_commits(limit: nil)
- commits = merge_request_diff_commits.with_users.limit(limit)
- .map { |commit| Commit.from_hash(commit.to_hash, project) }
+ def load_commits(limit: nil, load_from_gitaly: false)
+ if load_from_gitaly
+ commits = Gitlab::Git::Commit.batch_by_oid(repository, merge_request_diff_commits.limit(limit).map(&:sha))
+ commits = Commit.decorate(commits, project)
+ else
+ commits = merge_request_diff_commits.with_users.limit(limit)
+ .map { |commit| Commit.from_hash(commit.to_hash, project) }
+ end
CommitCollection
.new(merge_request.source_project, commits, merge_request.source_branch)
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 0e2842c3c11..868bee9961b 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -61,18 +61,10 @@ class Milestone < ApplicationRecord
end
def self.reference_pattern
- if Feature.enabled?(:milestone_reference_pattern, default_enabled: :yaml)
- new_reference_pattern
- else
- old_reference_pattern
- end
- end
-
- def self.new_reference_pattern
# NOTE: The iid pattern only matches when all characters on the expression
# are digits, so it will match %2 but not %2.1 because that's probably a
# milestone name and we want it to be matched as such.
- @new_reference_pattern ||= %r{
+ @reference_pattern ||= %r{
(#{Project.reference_pattern})?
#{Regexp.escape(reference_prefix)}
(?:
@@ -87,26 +79,6 @@ class Milestone < ApplicationRecord
}x
end
- # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/336268
- def self.old_reference_pattern
- # NOTE: The iid pattern only matches when all characters on the expression
- # are digits, so it will match %2 but not %2.1 because that's probably a
- # milestone name and we want it to be matched as such.
- @old_reference_pattern ||= %r{
- (#{Project.reference_pattern})?
- #{Regexp.escape(reference_prefix)}
- (?:
- (?<milestone_iid>
- \d+(?!\S\w)\b # Integer-based milestone iid, or
- ) |
- (?<milestone_name>
- [^"\s]+\b | # String-based single-word milestone title, or
- "[^"]+" # String-based multi-word milestone surrounded in quotes
- )
- )
- }x
- end
-
def self.link_reference_pattern
@link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 261639a4ec1..0c160cedb4d 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -18,6 +18,11 @@ class Namespace < ApplicationRecord
ignore_column :delayed_project_removal, remove_with: '14.1', remove_after: '2021-05-22'
+ # Tells ActiveRecord not to store the full class name, in order to space some space
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69794
+ self.store_full_sti_class = false
+ self.store_full_class_name = false
+
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
# Android repo (15) + some extra backup.
@@ -52,7 +57,7 @@ class Namespace < ApplicationRecord
has_one :admin_note, inverse_of: :namespace
accepts_nested_attributes_for :admin_note, update_only: true
- validates :owner, presence: true, unless: ->(n) { n.type == "Group" }
+ validates :owner, presence: true, if: ->(n) { n.owner_required? }
validates :name,
presence: true,
length: { maximum: 255 }
@@ -131,6 +136,21 @@ class Namespace < ApplicationRecord
attr_writer :root_ancestor, :emails_disabled_memoized
class << self
+ def sti_class_for(type_name)
+ case type_name
+ when 'Group'
+ Group
+ when 'Project'
+ Namespaces::ProjectNamespace
+ when 'User'
+ # TODO: We create a normal Namespace until
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68894 is ready
+ Namespace
+ else
+ Namespace
+ end
+ end
+
def by_path(path)
find_by('lower(path) = :value', value: path.downcase)
end
@@ -227,15 +247,27 @@ class Namespace < ApplicationRecord
end
def kind
- type == 'Group' ? 'group' : 'user'
+ return 'group' if group?
+ return 'project' if project?
+
+ 'user' # defaults to user
+ end
+
+ def group?
+ type == Group.sti_name
+ end
+
+ def project?
+ type == Namespaces::ProjectNamespace.sti_name
end
def user?
- kind == 'user'
+ # That last bit ensures we're considered a user namespace as a default
+ type.nil? || type == Namespaces::UserNamespace.sti_name || !(group? || project?)
end
- def group?
- type == 'Group'
+ def owner_required?
+ user?
end
def find_fork_of(project)
@@ -498,17 +530,27 @@ class Namespace < ApplicationRecord
def nesting_level_allowed
if ancestors.count > Group::NUMBER_OF_ANCESTORS_ALLOWED
- errors.add(:parent_id, 'has too deep level of nesting')
+ errors.add(:parent_id, _('has too deep level of nesting'))
end
end
def validate_parent_type
- return unless has_parent?
+ unless has_parent?
+ if project?
+ errors.add(:parent_id, _('must be set for a project namespace'))
+ end
+
+ return
+ end
+
+ if parent.project?
+ errors.add(:parent_id, _('project namespace cannot be the parent of another namespace'))
+ end
if user?
- errors.add(:parent_id, 'a user namespace cannot have a parent')
+ errors.add(:parent_id, _('cannot not be used for user namespace'))
elsif group?
- errors.add(:parent_id, 'a group cannot have a user namespace as its parent') if parent.user?
+ errors.add(:parent_id, _('user namespace cannot be the parent of another namespace')) if parent.user?
end
end
diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb
index 4a39bfebda0..170b29e9e21 100644
--- a/app/models/namespace_setting.rb
+++ b/app/models/namespace_setting.rb
@@ -2,6 +2,7 @@
class NamespaceSetting < ApplicationRecord
include CascadingNamespaceSettingAttribute
+ include Sanitizable
cascading_attr :delayed_project_removal
@@ -16,12 +17,17 @@ class NamespaceSetting < ApplicationRecord
before_validation :normalize_default_branch_name
+ enum jobs_to_be_done: { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5 }, _suffix: true
+
NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal,
:lock_delayed_project_removal, :resource_access_token_creation_allowed,
- :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap].freeze
+ :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap,
+ :setup_for_company, :jobs_to_be_done].freeze
self.primary_key = :namespace_id
+ sanitizes! :default_branch_name
+
def prevent_sharing_groups_outside_hierarchy
return super if namespace.root?
@@ -31,11 +37,7 @@ class NamespaceSetting < ApplicationRecord
private
def normalize_default_branch_name
- self.default_branch_name = if default_branch_name.blank?
- nil
- else
- Sanitize.fragment(self.default_branch_name)
- end
+ self.default_branch_name = default_branch_name.presence
end
def default_branch_name_content
diff --git a/app/models/namespaces/project_namespace.rb b/app/models/namespaces/project_namespace.rb
new file mode 100644
index 00000000000..d1806c1c088
--- /dev/null
+++ b/app/models/namespaces/project_namespace.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class ProjectNamespace < Namespace
+ has_one :project, foreign_key: :project_namespace_id, inverse_of: :project_namespace
+
+ validates :project, presence: true
+
+ def self.sti_name
+ 'Project'
+ end
+ end
+end
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 33e8c3e5172..d7130322ed1 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -74,7 +74,7 @@ module Namespaces
return super unless use_traversal_ids_for_root_ancestor?
strong_memoize(:root_ancestor) do
- if parent.nil?
+ if parent_id.nil?
self
else
Namespace.find_by(id: traversal_ids.first)
@@ -176,13 +176,14 @@ module Namespaces
# if you are walking up the ancestors or down the descendants.
if hierarchy_order
depth_sql = "ABS(#{traversal_ids.count} - array_length(traversal_ids, 1))"
- skope = skope.select(skope.arel_table[Arel.star], "#{depth_sql} as depth")
+ skope = skope.select(skope.default_select_columns, "#{depth_sql} as depth")
# The SELECT includes an extra depth attribute. We wrap the SQL in a
# standard SELECT to avoid mismatched attribute errors when trying to
# chain future ActiveRelation commands, and retain the ordering.
skope = self.class
.without_sti_condition
.from(skope, self.class.table_name)
+ .select(skope.arel_table[Arel.star])
.order(depth: hierarchy_order)
end
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 90fae8ef35d..2da0e48c2da 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -15,6 +15,28 @@ module Namespaces
select('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id')
end
+ def self_and_ancestors(include_self: true, hierarchy_order: nil)
+ return super unless use_traversal_ids_for_ancestor_scopes?
+
+ records = unscoped
+ .without_sti_condition
+ .where(id: without_sti_condition.select('unnest(traversal_ids)'))
+ .order_by_depth(hierarchy_order)
+ .normal_select
+
+ if include_self
+ records
+ else
+ records.where.not(id: all.as_ids)
+ end
+ end
+
+ def self_and_ancestor_ids(include_self: true)
+ return super unless use_traversal_ids_for_ancestor_scopes?
+
+ self_and_ancestors(include_self: include_self).as_ids
+ end
+
def self_and_descendants(include_self: true)
return super unless use_traversal_ids?
@@ -22,11 +44,7 @@ module Namespaces
distinct = records.select('DISTINCT on(namespaces.id) namespaces.*')
- # Produce a query of the form: SELECT * FROM namespaces;
- #
- # When we have queries that break this SELECT * format we can run in to errors.
- # For example `SELECT DISTINCT on(...)` will fail when we chain a `.count` c
- unscoped.without_sti_condition.from(distinct, :namespaces)
+ distinct.normal_select
end
def self_and_descendant_ids(include_self: true)
@@ -42,12 +60,35 @@ module Namespaces
unscope(where: :type)
end
+ def order_by_depth(hierarchy_order)
+ return all unless hierarchy_order
+
+ depth_order = hierarchy_order == :asc ? :desc : :asc
+
+ all
+ .select(Arel.star, 'array_length(traversal_ids, 1) as depth')
+ .order(depth: depth_order, id: :asc)
+ end
+
+ # Produce a query of the form: SELECT * FROM namespaces;
+ #
+ # When we have queries that break this SELECT * format we can run in to errors.
+ # For example `SELECT DISTINCT on(...)` will fail when we chain a `.count` c
+ def normal_select
+ unscoped.without_sti_condition.from(all, :namespaces)
+ end
+
private
def use_traversal_ids?
Feature.enabled?(:use_traversal_ids, default_enabled: :yaml)
end
+ def use_traversal_ids_for_ancestor_scopes?
+ Feature.enabled?(:use_traversal_ids_for_ancestor_scopes, default_enabled: :yaml) &&
+ use_traversal_ids?
+ end
+
def self_and_descendants_with_duplicates(include_self: true)
base_ids = select(:id)
diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb
index c1ada715d6d..8d2c5d3be5a 100644
--- a/app/models/namespaces/traversal/recursive.rb
+++ b/app/models/namespaces/traversal/recursive.rb
@@ -7,12 +7,12 @@ module Namespaces
include RecursiveScopes
def root_ancestor
- return self if parent.nil?
-
- if persisted?
+ if persisted? && !parent_id.nil?
strong_memoize(:root_ancestor) do
- recursive_self_and_ancestors.reorder(nil).find_by(parent_id: nil)
+ recursive_ancestors.reorder(nil).find_by(parent_id: nil)
end
+ elsif parent.nil?
+ self
else
parent.root_ancestor
end
diff --git a/app/models/namespaces/traversal/recursive_scopes.rb b/app/models/namespaces/traversal/recursive_scopes.rb
index be49d5d9d55..6659cefe095 100644
--- a/app/models/namespaces/traversal/recursive_scopes.rb
+++ b/app/models/namespaces/traversal/recursive_scopes.rb
@@ -10,6 +10,22 @@ module Namespaces
select('id')
end
+ def self_and_ancestors(include_self: true, hierarchy_order: nil)
+ records = Gitlab::ObjectHierarchy.new(all).base_and_ancestors(hierarchy_order: hierarchy_order)
+
+ if include_self
+ records
+ else
+ records.where.not(id: all.as_ids)
+ end
+ end
+ alias_method :recursive_self_and_ancestors, :self_and_ancestors
+
+ def self_and_ancestor_ids(include_self: true)
+ self_and_ancestors(include_self: include_self).as_ids
+ end
+ alias_method :recursive_self_and_ancestor_ids, :self_and_ancestor_ids
+
def descendant_ids
recursive_descendants.as_ids
end
diff --git a/app/models/namespaces/user_namespace.rb b/app/models/namespaces/user_namespace.rb
new file mode 100644
index 00000000000..517d68b118d
--- /dev/null
+++ b/app/models/namespaces/user_namespace.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# TODO: currently not created/mapped in the database, will be done in another issue
+# https://gitlab.com/gitlab-org/gitlab/-/issues/337102
+module Namespaces
+ class UserNamespace < Namespace
+ def self.sti_name
+ 'User'
+ end
+ end
+end
diff --git a/app/models/note.rb b/app/models/note.rb
index 34ffd7c91af..a8f5c305d9b 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -48,6 +48,9 @@ class Note < ApplicationRecord
# Attribute used to store the attributes that have been changed by quick actions.
attr_accessor :commands_changes
+ # Attribute used to determine whether keep_around_commits will be skipped for diff notes.
+ attr_accessor :skip_keep_around_commits
+
default_value_for :system, false
attr_mentionable :note, pipeline: :note
@@ -112,7 +115,6 @@ class Note < ApplicationRecord
scope :updated_after, ->(time) { where('updated_at > ?', time) }
scope :with_updated_at, ->(time) { where(updated_at: time) }
scope :with_suggestions, -> { joins(:suggestions) }
- scope :inc_author_project, -> { includes(:project, :author) }
scope :inc_author, -> { includes(:author) }
scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) }
scope :inc_relations_for_view, -> do
@@ -579,7 +581,8 @@ class Note < ApplicationRecord
end
def post_processed_cache_key
- cache_key_items = [cache_key, author.cache_key]
+ cache_key_items = [cache_key, author&.cache_key]
+ cache_key_items << project.team.human_max_access(author&.id) if author.present?
cache_key_items << Digest::SHA1.hexdigest(redacted_note_html) if redacted_note_html.present?
cache_key_items.join(':')
@@ -603,14 +606,6 @@ class Note < ApplicationRecord
private
- # 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)
-
- user_mentions.first_or_initialize
- end
-
def system_note_viewable_by?(user)
return true unless system_note_metadata
@@ -648,7 +643,7 @@ class Note < ApplicationRecord
user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count
else
refs = all_references(user)
- refs.all.any? && refs.stateful_not_visible_counter == 0
+ refs.all.any? && refs.all_visible?
end
end
diff --git a/app/models/onboarding_progress.rb b/app/models/onboarding_progress.rb
index 9185547d7cd..c12309d1852 100644
--- a/app/models/onboarding_progress.rb
+++ b/app/models/onboarding_progress.rb
@@ -45,7 +45,7 @@ class OnboardingProgress < ApplicationRecord
def onboard(namespace)
return unless root_namespace?(namespace)
- safe_find_or_create_by(namespace: namespace)
+ create(namespace: namespace)
end
def onboarding?(namespace)
diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb
index 450a5970ad8..46810749b18 100644
--- a/app/models/operations/feature_flag.rb
+++ b/app/models/operations/feature_flag.rb
@@ -17,6 +17,7 @@ module Operations
has_internal_id :iid, scope: :project
default_value_for :active, true
+ default_value_for :version, :new_version_flag
# scopes exists only for the first version
has_many :scopes, class_name: 'Operations::FeatureFlagScope'
@@ -39,8 +40,6 @@ module Operations
validate :first_default_scope, on: :create, if: :has_scopes?
validate :version_associations
- before_create :build_default_scope, if: -> { legacy_flag? && scopes.none? }
-
accepts_nested_attributes_for :scopes, allow_destroy: true
accepts_nested_attributes_for :strategies, allow_destroy: true
@@ -52,7 +51,6 @@ module Operations
scope :new_version_only, -> { where(version: :new_version_flag)}
enum version: {
- legacy_flag: 1,
new_version_flag: 2
}
@@ -127,8 +125,6 @@ module Operations
def version_associations
if new_version_flag? && scopes.any?
errors.add(:version_associations, 'version 2 feature flags may not have scopes')
- elsif legacy_flag? && strategies.any?
- errors.add(:version_associations, 'version 1 feature flags may not have strategies')
end
end
diff --git a/app/models/operations/feature_flag_scope.rb b/app/models/operations/feature_flag_scope.rb
index 78be29f2531..9068ca0f588 100644
--- a/app/models/operations/feature_flag_scope.rb
+++ b/app/models/operations/feature_flag_scope.rb
@@ -1,5 +1,9 @@
# frozen_string_literal: true
+# All of the legacy flags have been removed in 14.1, including all of the
+# `operations_feature_flag_scopes` rows. Therefore, this model and the database
+# table are unused and should be removed.
+
module Operations
class FeatureFlagScope < ApplicationRecord
prepend HasEnvironmentScope
diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb
index 4ea127fc222..34eae6ab5dc 100644
--- a/app/models/packages/package.rb
+++ b/app/models/packages/package.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
class Packages::Package < ApplicationRecord
+ include EachBatch
include Sortable
include Gitlab::SQL::Pattern
include UsageStatistics
@@ -104,6 +105,7 @@ class Packages::Package < ApplicationRecord
scope :including_build_info, -> { includes(pipelines: :user) }
scope :including_project_route, -> { includes(project: { namespace: :route }) }
scope :including_tags, -> { includes(:tags) }
+ scope :including_dependency_links, -> { includes(dependency_links: :dependency) }
scope :with_conan_channel, ->(package_channel) do
joins(:conan_metadatum).where(packages_conan_metadata: { package_channel: package_channel })
@@ -291,6 +293,13 @@ class Packages::Package < ApplicationRecord
::Packages::Maven::Metadata::SyncWorker.perform_async(user.id, project.id, name)
end
+ def create_build_infos!(build)
+ return unless build&.pipeline
+
+ # TODO: use an upsert call when https://gitlab.com/gitlab-org/gitlab/-/issues/339093 is implemented
+ build_infos.find_or_create_by!(pipeline: build.pipeline)
+ end
+
private
def composer_tag_version?
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index 8aa19397086..14701b8a800 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -77,6 +77,10 @@ class Packages::PackageFile < ApplicationRecord
.where(packages_conan_file_metadata: { conan_package_reference: conan_package_reference })
end
+ def self.most_recent!
+ recent.first!
+ end
+
mount_file_store_uploader Packages::PackageFileUploader
update_project_statistics project_statistics_name: :packages_size
@@ -89,6 +93,24 @@ class Packages::PackageFile < ApplicationRecord
skip_callback :commit, :after, :remove_previously_stored_file, if: :execute_move_in_object_storage?
after_commit :move_in_object_storage, if: :execute_move_in_object_storage?
+ # Returns the most recent package files for *each* of the given packages.
+ # The order is not guaranteed.
+ def self.most_recent_for(packages, extra_join: nil, extra_where: nil)
+ cte_name = :packages_cte
+ cte = Gitlab::SQL::CTE.new(cte_name, packages.select(:id))
+
+ package_files = ::Packages::PackageFile.limit_recent(1)
+ .where(arel_table[:package_id].eq(Arel.sql("#{cte_name}.id")))
+
+ package_files = package_files.joins(extra_join) if extra_join
+ package_files = package_files.where(extra_where) if extra_where
+
+ query = select('finder.*')
+ .from([Arel.sql(cte_name.to_s), package_files.arel.lateral.as('finder')])
+
+ query.with(cte.to_arel)
+ end
+
def download_path
Gitlab::Routing.url_helpers.download_project_package_file_path(project, self)
end
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index 294a4e85d1f..da6ef035c54 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -16,6 +16,7 @@ class PagesDeployment < ApplicationRecord
scope :migrated_from_legacy_storage, -> { where(file: MIGRATED_FILE_NAME) }
scope :with_files_stored_locally, -> { where(file_store: ::ObjectStorage::Store::LOCAL) }
scope :with_files_stored_remotely, -> { where(file_store: ::ObjectStorage::Store::REMOTE) }
+ scope :project_id_in, ->(ids) { where(project_id: ids) }
validates :file, presence: true
validates :file_store, presence: true, inclusion: { in: ObjectStorage::SUPPORTED_STORES }
@@ -27,10 +28,6 @@ class PagesDeployment < ApplicationRecord
mount_file_store_uploader ::Pages::DeploymentUploader
- def log_geo_deleted_event
- # this is to be adressed in https://gitlab.com/groups/gitlab-org/-/epics/589
- end
-
def migrated?
file.filename == MIGRATED_FILE_NAME
end
@@ -41,3 +38,5 @@ class PagesDeployment < ApplicationRecord
self.size = file.size
end
end
+
+PagesDeployment.prepend_mod
diff --git a/app/models/postgresql/detached_partition.rb b/app/models/postgresql/detached_partition.rb
index 76b299ff9d4..12b48895e0c 100644
--- a/app/models/postgresql/detached_partition.rb
+++ b/app/models/postgresql/detached_partition.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Postgresql
- class DetachedPartition < ApplicationRecord
+ class DetachedPartition < ::Gitlab::Database::SharedModel
scope :ready_to_drop, -> { where('drop_after < ?', Time.current) }
end
end
diff --git a/app/models/preloaders/commit_status_preloader.rb b/app/models/preloaders/commit_status_preloader.rb
new file mode 100644
index 00000000000..535dd24ba6b
--- /dev/null
+++ b/app/models/preloaders/commit_status_preloader.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Preloaders
+ class CommitStatusPreloader
+ CLASSES = [::Ci::Build, ::Ci::Bridge, ::GenericCommitStatus].freeze
+
+ def initialize(statuses)
+ @statuses = statuses
+ end
+
+ def execute(relations)
+ preloader = ActiveRecord::Associations::Preloader.new
+
+ CLASSES.each do |klass|
+ preloader.preload(objects(klass), associations(klass, relations))
+ end
+ end
+
+ private
+
+ def objects(klass)
+ @statuses.select { |job| job.is_a?(klass) }
+ end
+
+ def associations(klass, relations)
+ klass.reflections.keys.map(&:to_sym) & relations.map(&:to_sym)
+ end
+ end
+end
diff --git a/app/models/preloaders/merge_requests_preloader.rb b/app/models/preloaders/merge_requests_preloader.rb
new file mode 100644
index 00000000000..cefe8408cab
--- /dev/null
+++ b/app/models/preloaders/merge_requests_preloader.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Preloaders
+ class MergeRequestsPreloader
+ attr_reader :merge_requests
+
+ def initialize(merge_requests)
+ @merge_requests = merge_requests
+ end
+
+ def execute
+ preloader = ActiveRecord::Associations::Preloader.new
+ preloader.preload(merge_requests, { target_project: [:project_feature] })
+ merge_requests.each do |merge_request|
+ merge_request.lazy_upvotes_count
+ end
+ end
+ end
+end
diff --git a/app/models/preloaders/user_max_access_level_in_groups_preloader.rb b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb
new file mode 100644
index 00000000000..14f1d271572
--- /dev/null
+++ b/app/models/preloaders/user_max_access_level_in_groups_preloader.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Preloaders
+ # This class preloads the max access level (role) for the user within the given groups and
+ # stores the values in requests store.
+ # Will only be able to preload max access level for groups where the user is a direct member
+ class UserMaxAccessLevelInGroupsPreloader
+ include BulkMemberAccessLoad
+
+ def initialize(groups, user)
+ @groups = groups
+ @user = user
+ end
+
+ def execute
+ group_memberships = GroupMember.active_without_invites_and_requests
+ .non_minimal_access
+ .where(user: @user, source_id: @groups)
+ .group(:source_id)
+ .maximum(:access_level)
+
+ group_memberships.each do |group_id, max_access_level|
+ merge_value_to_request_store(User, @user.id, group_id, max_access_level)
+ end
+ end
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 81b04e1316c..74ffeef797e 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -103,6 +103,8 @@ class Project < ApplicationRecord
after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? }
+ after_save :save_topics
+
after_create -> { create_or_load_association(:project_feature) }
after_create -> { create_or_load_association(:ci_cd_settings) }
@@ -118,7 +120,6 @@ class Project < ApplicationRecord
use_fast_destroy :build_trace_chunks
- after_destroy -> { run_after_commit { legacy_remove_pages } }
after_destroy :remove_exports
after_validation :check_pending_delete
@@ -127,12 +128,31 @@ class Project < ApplicationRecord
after_initialize :use_hashed_storage
after_create :check_repository_absence!
+ # Required during the `ActsAsTaggableOn::Tag -> Topic` migration
+ # TODO: remove 'acts_as_ordered_taggable_on' and ':topics_acts_as_taggable' in the further process of the migration
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/335946
acts_as_ordered_taggable_on :topics
+ has_many :topics_acts_as_taggable, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") },
+ class_name: 'ActsAsTaggableOn::Tag',
+ through: :topic_taggings,
+ source: :tag
+
+ has_many :project_topics, -> { order(:id) }, class_name: 'Projects::ProjectTopic'
+ has_many :topics, through: :project_topics, class_name: 'Projects::Topic'
+
+ # Required during the `ActsAsTaggableOn::Tag -> Topic` migration
+ # TODO: remove 'topics' in the further process of the migration
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/335946
+ alias_method :topics_new, :topics
+ def topics
+ self.topics_acts_as_taggable + self.topics_new
+ end
attr_accessor :old_path_with_namespace
attr_accessor :template_name
attr_writer :pipeline_status
attr_accessor :skip_disk_validation
+ attr_writer :topic_list
alias_attribute :title, :name
@@ -141,6 +161,9 @@ class Project < ApplicationRecord
belongs_to :creator, class_name: 'User'
belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'namespace_id'
belongs_to :namespace
+ # Sync deletion via DB Trigger to ensure we do not have
+ # a project without a project_namespace (or vice-versa)
+ belongs_to :project_namespace, class_name: 'Namespaces::ProjectNamespace', foreign_key: 'project_namespace_id', inverse_of: :project
alias_method :parent, :namespace
alias_attribute :parent_id, :namespace_id
@@ -188,6 +211,7 @@ class Project < ApplicationRecord
has_one :unify_circuit_integration, class_name: 'Integrations::UnifyCircuit'
has_one :webex_teams_integration, class_name: 'Integrations::WebexTeams'
has_one :youtrack_integration, class_name: 'Integrations::Youtrack'
+ has_one :zentao_integration, class_name: 'Integrations::Zentao'
has_one :root_of_fork_network,
foreign_key: 'root_project_id',
@@ -317,6 +341,7 @@ class Project < ApplicationRecord
# build traces. Currently there's no efficient way of removing this data in
# bulk that doesn't involve loading the rows into memory. As a result we're
# still using `dependent: :destroy` here.
+ has_many :pending_builds, class_name: 'Ci::PendingBuild'
has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :processables, class_name: 'Ci::Processable', inverse_of: :project
has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
@@ -355,6 +380,7 @@ class Project < ApplicationRecord
has_many :jira_imports, -> { order 'jira_imports.created_at' }, class_name: 'JiraImportState', inverse_of: :project
has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult'
+ has_many :ci_feature_usages, class_name: 'Projects::CiFeatureUsage'
has_many :repository_storage_moves, class_name: 'Projects::RepositoryStorageMove', inverse_of: :container
@@ -503,6 +529,7 @@ class Project < ApplicationRecord
scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) }
scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) }
# Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name
+ scope :projects_order_id_asc, -> { reorder(self.arel_table['id'].asc) }
scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) }
scope :sorted_by_similarity_desc, -> (search, include_in_select: false) do
@@ -623,6 +650,19 @@ class Project < ApplicationRecord
joins(:service_desk_setting).where('service_desk_settings.project_key' => key)
end
+ scope :with_topic, ->(topic_name) do
+ topic = Projects::Topic.find_by_name(topic_name)
+ acts_as_taggable_on_topic = ActsAsTaggableOn::Tag.find_by_name(topic_name)
+
+ return none unless topic || acts_as_taggable_on_topic
+
+ relations = []
+ relations << where(id: topic.project_topics.select(:project_id)) if topic
+ relations << where(id: acts_as_taggable_on_topic.taggings.select(:taggable_id)) if acts_as_taggable_on_topic
+
+ Project.from_union(relations)
+ end
+
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
chronic_duration_attr :build_timeout_human_readable, :build_timeout,
@@ -638,7 +678,7 @@ class Project < ApplicationRecord
mount_uploader :bfg_object_map, AttachmentUploader
def self.with_api_entity_associations
- preload(:project_feature, :route, :topics, :group, :timelogs, namespace: [:route, :owner])
+ preload(:project_feature, :route, :topics, :topics_acts_as_taggable, :group, :timelogs, namespace: [:route, :owner])
end
def self.with_web_entity_associations
@@ -1421,7 +1461,7 @@ class Project < ApplicationRecord
end
def disabled_integrations
- []
+ [:zentao]
end
def find_or_initialize_integration(name)
@@ -1640,6 +1680,10 @@ class Project < ApplicationRecord
end
end
+ def membership_locked?
+ false
+ end
+
def bots
users.project_bot
end
@@ -1747,6 +1791,9 @@ class Project < ApplicationRecord
Ci::Runner.from_union([runners, group_runners, available_shared_runners])
end
+ # Once issue 339937 is fixed, please search for all mentioned of
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/339937,
+ # and remove the allow_cross_joins_across_databases.
def active_runners
strong_memoize(:active_runners) do
all_available_runners.active
@@ -1754,7 +1801,9 @@ class Project < ApplicationRecord
end
def any_online_runners?(&block)
- online_runners_with_tags.any?(&block)
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937') do
+ online_runners_with_tags.any?(&block)
+ end
end
def valid_runners_token?(token)
@@ -1763,7 +1812,15 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def open_issues_count(current_user = nil)
- Projects::OpenIssuesCountService.new(self, current_user).count
+ return Projects::OpenIssuesCountService.new(self, current_user).count unless current_user.nil?
+
+ BatchLoader.for(self).batch(replace_methods: false) do |projects, loader|
+ issues_count_per_project = ::Projects::BatchOpenIssuesCountService.new(projects).refresh_cache_and_retrieve_data
+
+ issues_count_per_project.each do |project, count|
+ loader.call(project, count)
+ end
+ end
end
# rubocop: enable CodeReuse/ServiceClass
@@ -1849,27 +1906,6 @@ class Project < ApplicationRecord
.delete_all
end
- # TODO: remove this method https://gitlab.com/gitlab-org/gitlab/-/issues/320775
- # rubocop: disable CodeReuse/ServiceClass
- def legacy_remove_pages
- return unless ::Settings.pages.local_store.enabled
-
- # Projects with a missing namespace cannot have their pages removed
- return unless namespace
-
- mark_pages_as_not_deployed unless destroyed?
-
- # 1. We rename pages to temporary directory
- # 2. We wait 5 minutes, due to NFS caching
- # 3. We asynchronously remove pages with force
- temp_path = "#{path}.#{SecureRandom.hex}.deleted"
-
- if Gitlab::PagesTransfer.new.rename_project(path, temp_path, namespace.full_path)
- PagesWorker.perform_in(5.minutes, :remove, namespace.full_path, temp_path)
- end
- end
- # rubocop: enable CodeReuse/ServiceClass
-
def mark_pages_as_deployed(artifacts_archive: nil)
ensure_pages_metadatum.update!(deployed: true, artifacts_archive: artifacts_archive)
end
@@ -2093,6 +2129,10 @@ class Project < ApplicationRecord
# Docker doesn't allow. The proxy expects it to be downcased.
value: "#{Gitlab.host_with_port}/#{namespace.root_ancestor.path.downcase}#{DependencyProxy::URL_SUFFIX}"
)
+ variables.append(
+ key: 'CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX',
+ value: "#{Gitlab.host_with_port}/#{namespace.full_path.downcase}#{DependencyProxy::URL_SUFFIX}"
+ )
end
end
@@ -2239,7 +2279,7 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def forks_count
- BatchLoader.for(self).batch do |projects, loader|
+ BatchLoader.for(self).batch(replace_methods: false) do |projects, loader|
fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data
fork_count_per_project.each do |project, count|
@@ -2491,6 +2531,10 @@ class Project < ApplicationRecord
ci_config_path.blank? || ci_config_path == Gitlab::FileDetector::PATTERNS[:gitlab_ci]
end
+ def uses_external_project_ci_config?
+ !!(ci_config_path =~ %r{@.+/.+})
+ end
+
def limited_protected_branches(limit)
protected_branches.limit(limit)
end
@@ -2599,6 +2643,10 @@ class Project < ApplicationRecord
repository.gitlab_ci_yml_for(sha, ci_config_path_or_default)
end
+ def ci_config_external_project
+ Project.find_by_full_path(ci_config_path.split('@', 2).last)
+ end
+
def enabled_group_deploy_keys
return GroupDeployKey.none unless group
@@ -2669,8 +2717,37 @@ class Project < ApplicationRecord
ci_cd_settings.group_runners_enabled?
end
+ def topic_list
+ self.topics.map(&:name)
+ end
+
+ override :after_change_head_branch_does_not_exist
+ def after_change_head_branch_does_not_exist(branch)
+ self.errors.add(:base, _("Could not change HEAD: branch '%{branch}' does not exist") % { branch: branch })
+ end
+
private
+ def save_topics
+ return if @topic_list.nil?
+
+ @topic_list = @topic_list.split(',') if @topic_list.instance_of?(String)
+ @topic_list = @topic_list.map(&:strip).uniq.reject(&:empty?)
+
+ if @topic_list != self.topic_list || self.topics_acts_as_taggable.any?
+ self.topics_new.delete_all
+ self.topics = @topic_list.map { |topic| Projects::Topic.find_or_create_by(name: topic) }
+
+ # Remove old topics (ActsAsTaggableOn::Tag)
+ # Required during the `ActsAsTaggableOn::Tag -> Topic` migration
+ # TODO: remove in the further process of the migration
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/335946
+ self.topic_taggings.clear
+ end
+
+ @topic_list = nil
+ end
+
def find_integration(integrations, name)
integrations.find { _1.to_param == name }
end
@@ -2832,12 +2909,8 @@ class Project < ApplicationRecord
update_column(:has_external_issue_tracker, integrations.external_issue_trackers.any?) if Gitlab::Database.read_write?
end
- def active_runners_with_tags
- @active_runners_with_tags ||= active_runners.with_tags
- end
-
def online_runners_with_tags
- @online_runners_with_tags ||= active_runners_with_tags.online
+ @online_runners_with_tags ||= active_runners.with_tags.online
end
end
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index aea8abecd74..676c28d5e1b 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -54,7 +54,6 @@ class ProjectFeature < ApplicationRecord
validates :project, presence: true
validate :repository_children_level
- validate :allowed_access_levels
default_value_for :builds_access_level, value: ENABLED, allows_nil: false
default_value_for :issues_access_level, value: ENABLED, allows_nil: false
@@ -110,17 +109,6 @@ class ProjectFeature < ApplicationRecord
%i(merge_requests_access_level builds_access_level).each(&validator)
end
- # Validates access level for other than pages cannot be PUBLIC
- def allowed_access_levels
- validator = lambda do |field|
- level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend
- not_allowed = level > ENABLED
- self.errors.add(field, "cannot have public visibility level") if not_allowed
- end
-
- (FEATURES - %i(pages)).each {|f| validator.call("#{f}_access_level")}
- end
-
def get_permission(user, feature)
case access_level(feature)
when DISABLED
@@ -142,6 +130,10 @@ class ProjectFeature < ApplicationRecord
project.team.member?(user, ProjectFeature.required_minimum_access_level(feature))
end
+
+ def feature_validation_exclusion
+ %i(pages)
+ end
end
ProjectFeature.prepend_mod_with('ProjectFeature')
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 4ae3bc01a01..774d81156b7 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -42,7 +42,7 @@ class ProjectTeam
end
def add_users(users, access_level, current_user: nil, expires_at: nil)
- Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
+ Members::Projects::BulkCreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass
project,
users,
access_level,
diff --git a/app/models/projects/project_topic.rb b/app/models/projects/project_topic.rb
new file mode 100644
index 00000000000..d4b456ef482
--- /dev/null
+++ b/app/models/projects/project_topic.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Projects
+ class ProjectTopic < ApplicationRecord
+ belongs_to :project
+ belongs_to :topic
+ end
+end
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
new file mode 100644
index 00000000000..a17aa550edb
--- /dev/null
+++ b/app/models/projects/topic.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Projects
+ class Topic < ApplicationRecord
+ validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
+
+ has_many :project_topics, class_name: 'Projects::ProjectTopic'
+ has_many :projects, through: :project_topics
+ end
+end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 3df8fe31826..3d32144e0f8 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -26,7 +26,9 @@ class ProtectedBranch < ApplicationRecord
def self.protected?(project, ref_name)
return true if project.empty_repo? && project.default_branch_protected?
- self.matching(ref_name, protected_refs: protected_refs(project)).present?
+ Rails.cache.fetch("protected_ref-#{ref_name}-#{project.cache_key}") do
+ self.matching(ref_name, protected_refs: protected_refs(project)).present?
+ end
end
def self.allow_force_push?(project, ref_name)
diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb
index 8358be35470..441b94e1855 100644
--- a/app/models/push_event_payload.rb
+++ b/app/models/push_event_payload.rb
@@ -2,6 +2,9 @@
class PushEventPayload < ApplicationRecord
extend SuppressCompositePrimaryKeyWarning
+ include IgnorableColumns
+
+ ignore_columns :event_id_convert_to_bigint, remove_with: '14.4', remove_after: '2021-10-22'
include ShaAttribute
diff --git a/app/models/release.rb b/app/models/release.rb
index aad1cbeabdb..0dd71c6ebfb 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -33,7 +33,6 @@ class Release < ApplicationRecord
includes(:author, :evidences, :milestones, :links, :sorted_links,
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) }
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 0164d6fed93..f20b306c806 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -161,8 +161,8 @@ class Repository
CommitCollection.new(container, commits, ref)
end
- def commits_between(from, to)
- commits = Gitlab::Git::Commit.between(raw_repository, from, to)
+ def commits_between(from, to, limit: nil)
+ commits = Gitlab::Git::Commit.between(raw_repository, from, to, limit: limit)
commits = Commit.decorate(commits, container) if commits.present?
commits
end
@@ -191,7 +191,11 @@ class Repository
end
def find_tag(name)
- tags.find { |tag| tag.name == name }
+ if @tags.blank? && Feature.enabled?(:find_tag_via_gitaly, project, default_enabled: :yaml)
+ raw_repository.find_tag(name)
+ else
+ tags.find { |tag| tag.name == name }
+ end
end
def ambiguous_ref?(ref)
@@ -627,7 +631,14 @@ class Repository
def license
return unless license_key
- Licensee::License.new(license_key)
+ licensee_object = Licensee::License.new(license_key)
+
+ return if licensee_object.name.blank?
+
+ licensee_object
+ rescue Licensee::InvalidLicense => ex
+ Gitlab::ErrorTracking.track_exception(ex)
+ nil
end
memoize_method :license
@@ -721,18 +732,9 @@ class Repository
end
def tags_sorted_by(value)
- case value
- when 'name_asc'
- VersionSorter.sort(tags) { |tag| tag.name }
- when 'name_desc'
- VersionSorter.rsort(tags) { |tag| tag.name }
- when 'updated_desc'
- tags_sorted_by_committed_date.reverse
- when 'updated_asc'
- tags_sorted_by_committed_date
- else
- tags
- end
+ return raw_repository.tags(sort_by: value) if Feature.enabled?(:gitaly_tags_finder, project, default_enabled: :yaml)
+
+ tags_ruby_sort(value)
end
# Params:
@@ -1125,11 +1127,16 @@ class Repository
copy_gitattributes(branch)
after_change_head
else
- container.errors.add(:base, _("Could not change HEAD: branch '%{branch}' does not exist") % { branch: branch })
+ container.after_change_head_branch_does_not_exist(branch)
+
false
end
end
+ def cache
+ @cache ||= Gitlab::RepositoryCache.new(self)
+ end
+
private
# TODO Genericize finder, later split this on finders by Ref or Oid
@@ -1144,10 +1151,6 @@ class Repository
::Commit.new(commit, container) if commit
end
- def cache
- @cache ||= Gitlab::RepositoryCache.new(self)
- end
-
def redis_set_cache
@redis_set_cache ||= Gitlab::RepositorySetCache.new(self)
end
@@ -1160,6 +1163,23 @@ class Repository
@request_store_cache ||= Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore)
end
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/339741
+ def tags_ruby_sort(value)
+ case value
+ when 'name_asc'
+ VersionSorter.sort(tags) { |tag| tag.name }
+ when 'name_desc'
+ VersionSorter.rsort(tags) { |tag| tag.name }
+ when 'updated_desc'
+ tags_sorted_by_committed_date.reverse
+ when 'updated_asc'
+ tags_sorted_by_committed_date
+ else
+ tags
+ end
+ end
+
+ # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/339741
def tags_sorted_by_committed_date
# Annotated tags can point to any object (e.g. a blob), but generally
# tags point to a commit. If we don't have a commit, then just default
diff --git a/app/models/service_desk_setting.rb b/app/models/service_desk_setting.rb
index 1c854cc9941..6dd7415d928 100644
--- a/app/models/service_desk_setting.rb
+++ b/app/models/service_desk_setting.rb
@@ -19,7 +19,11 @@ class ServiceDeskSetting < ApplicationRecord
strong_memoize(:issue_template_content) do
next unless issue_template_key.present?
- Gitlab::Template::IssueTemplate.find(issue_template_key, project).content
+ TemplateFinder.new(
+ :issues, project,
+ name: issue_template_key,
+ source_template_project: source_template_project
+ ).execute.content
rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
end
end
@@ -42,6 +46,10 @@ class ServiceDeskSetting < ApplicationRecord
private
+ def source_template_project
+ nil
+ end
+
def projects_with_same_slug_and_key_exists?
return false unless project_key
@@ -53,3 +61,5 @@ class ServiceDeskSetting < ApplicationRecord
end
end
end
+
+ServiceDeskSetting.prepend_mod
diff --git a/app/models/shard.rb b/app/models/shard.rb
index 335a279c6aa..9f0039d8bf9 100644
--- a/app/models/shard.rb
+++ b/app/models/shard.rb
@@ -18,10 +18,6 @@ class Shard < ApplicationRecord
end
def self.by_name(name)
- transaction(requires_new: true) do
- find_or_create_by(name: name)
- end
- rescue ActiveRecord::RecordNotUnique
- retry
+ safe_find_or_create_by(name: name)
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index cb0f15c04cb..b5f0251f639 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -39,6 +39,12 @@ class User < ApplicationRecord
MAX_USERNAME_LENGTH = 255
MIN_USERNAME_LENGTH = 2
+ SECONDARY_EMAIL_ATTRIBUTES = [
+ :commit_email,
+ :notification_email,
+ :public_email
+ ].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
@@ -181,7 +187,7 @@ class User < ApplicationRecord
has_many :todos
has_many :notification_settings
has_many :award_emoji, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id # rubocop:disable Cop/ActiveRecordDependent
+ has_many :triggers, class_name: 'Ci::Trigger', foreign_key: :owner_id
has_many :issue_assignees, inverse_of: :assignee
has_many :merge_request_assignees, inverse_of: :assignee
@@ -194,6 +200,7 @@ class User < ApplicationRecord
has_many :custom_attributes, class_name: 'UserCustomAttribute'
has_many :callouts, class_name: 'UserCallout'
+ has_many :group_callouts, class_name: 'Users::GroupCallout'
has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
@@ -222,10 +229,9 @@ class User < ApplicationRecord
validates :first_name, length: { maximum: 127 }
validates :last_name, length: { maximum: 127 }
validates :email, confirmation: true
- validates :notification_email, presence: true
- validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email }
+ validates :notification_email, devise_email: true, allow_blank: true, if: ->(user) { user.notification_email != user.email }
validates :public_email, uniqueness: true, devise_email: true, allow_blank: true
- validates :commit_email, devise_email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email }
+ validates :commit_email, devise_email: true, allow_blank: true, if: ->(user) { user.commit_email != user.email && user.commit_email != Gitlab::PrivateCommitEmail::TOKEN }
validates :projects_limit,
presence: true,
numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE }
@@ -247,12 +253,10 @@ class User < ApplicationRecord
validates :color_scheme_id, allow_nil: true, inclusion: { in: Gitlab::ColorSchemes.valid_ids,
message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } }
+ validates :website_url, allow_blank: true, url: true, if: :website_url_changed?
+
before_validation :sanitize_attrs
- before_validation :set_public_email, if: :public_email_changed?
- before_validation :set_commit_email, if: :commit_email_changed?
before_save :default_private_profile_to_false
- before_save :set_public_email, if: :public_email_changed? # in case validation is skipped
- before_save :set_commit_email, if: :commit_email_changed? # in case validation is skipped
before_save :ensure_incoming_email_token
before_save :ensure_user_rights_and_limits, if: ->(user) { user.new_record? || user.external_changed? }
before_save :skip_reconfirmation!, if: ->(user) { user.email_changed? && user.read_only_attribute?(:email) }
@@ -302,14 +306,13 @@ class User < ApplicationRecord
:gitpod_enabled, :gitpod_enabled=,
:setup_for_company, :setup_for_company=,
:render_whitespace_in_code, :render_whitespace_in_code=,
- :experience_level, :experience_level=,
:markdown_surround_selection, :markdown_surround_selection=,
to: :user_preference
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 :bio, :bio=, to: :user_detail, allow_nil: true
delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true
delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true
delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true
@@ -347,6 +350,10 @@ class User < ApplicationRecord
transition active: :banned
end
+ event :unban do
+ transition banned: :active
+ end
+
event :deactivate do
# Any additional changes to this event should be also
# reflected in app/workers/users/deactivate_dormant_users_worker.rb
@@ -374,7 +381,9 @@ class User < ApplicationRecord
end
after_transition any => :deactivated do |user|
- NotificationService.new.user_deactivated(user.name, user.notification_email)
+ next unless Gitlab::CurrentSettings.user_deactivation_emails_enabled
+
+ NotificationService.new.user_deactivated(user.name, user.notification_email_or_default)
end
# rubocop: enable CodeReuse/ServiceClass
@@ -922,51 +931,18 @@ class User < ApplicationRecord
end
end
- def notification_email_verified
- return if read_attribute(:notification_email).blank? || temp_oauth_email?
-
- errors.add(:notification_email, _("must be an email you have verified")) unless verified_emails.include?(notification_email)
- end
-
- def public_email_verified
- return if public_email.blank?
-
- errors.add(:public_email, _("must be an email you have verified")) unless verified_emails.include?(public_email)
- end
-
- def commit_email_verified
- return if read_attribute(:commit_email).blank?
-
- errors.add(:commit_email, _("must be an email you have verified")) unless verified_emails.include?(commit_email)
- end
-
- # Define commit_email-related attribute methods explicitly instead of relying
- # on ActiveRecord to provide them. Some of the specs use the current state of
- # the model code but an older database schema, so we need to guard against the
- # possibility of the commit_email column not existing.
-
- def commit_email
- return self.email unless has_attribute?(:commit_email)
-
- if super == Gitlab::PrivateCommitEmail::TOKEN
+ def commit_email_or_default
+ if self.commit_email == Gitlab::PrivateCommitEmail::TOKEN
return private_commit_email
end
# The commit email is the same as the primary email if undefined
- super.presence || self.email
+ self.commit_email.presence || self.email
end
- def commit_email=(email)
- super if has_attribute?(:commit_email)
- end
-
- def commit_email_changed?
- has_attribute?(:commit_email) && super
- end
-
- def notification_email
+ def notification_email_or_default
# The notification email is the same as the primary email if undefined
- super.presence || self.email
+ self.notification_email.presence || self.email
end
def private_commit_email
@@ -1009,7 +985,11 @@ class User < ApplicationRecord
# Returns the groups a user is a member of, either directly or through a parent group
def membership_groups
- Gitlab::ObjectHierarchy.new(groups).base_and_descendants
+ if Feature.enabled?(:linear_user_membership_groups, self, default_enabled: :yaml)
+ groups.self_and_descendants
+ else
+ Gitlab::ObjectHierarchy.new(groups).base_and_descendants
+ end
end
# Returns a relation of groups the user has access to, including their parent
@@ -1292,29 +1272,15 @@ class User < ApplicationRecord
self.name = self.name.gsub(%r{</?[^>]*>}, '')
end
- def set_notification_email
- if notification_email.blank? || all_emails.exclude?(notification_email)
- self.notification_email = email
- end
- end
-
- def set_public_email
- if public_email.blank? || all_emails.exclude?(public_email)
- self.public_email = ''
- end
- end
-
- def set_commit_email
- if commit_email.blank? || verified_emails.exclude?(commit_email)
- self.commit_email = nil
+ def unset_secondary_emails_matching_deleted_email!(deleted_email)
+ secondary_email_attribute_changed = false
+ SECONDARY_EMAIL_ATTRIBUTES.each do |attribute|
+ if read_attribute(attribute) == deleted_email
+ self.write_attribute(attribute, nil)
+ secondary_email_attribute_changed = true
+ end
end
- end
-
- def update_secondary_emails!
- set_notification_email
- set_public_email
- set_commit_email
- save if notification_email_changed? || public_email_changed? || commit_email_changed?
+ save if secondary_email_attribute_changed
end
def admin_unsubscribe!
@@ -1569,7 +1535,11 @@ class User < ApplicationRecord
end
def manageable_groups(include_groups_with_developer_maintainer_access: false)
- owned_and_maintainer_group_hierarchy = Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants
+ owned_and_maintainer_group_hierarchy = if Feature.enabled?(:linear_user_manageable_groups, self, default_enabled: :yaml)
+ owned_or_maintainers_groups.self_and_descendants
+ else
+ Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants
+ end
if include_groups_with_developer_maintainer_access
union_sql = ::Gitlab::SQL::Union.new(
@@ -1628,6 +1598,8 @@ class User < ApplicationRecord
true
end
+ # TODO Please check all callers and remove allow_cross_joins_across_databases,
+ # when https://gitlab.com/gitlab-org/gitlab/-/issues/336436 is done.
def ci_owned_runners
@ci_owned_runners ||= begin
project_runners = Ci::RunnerProject
@@ -1644,9 +1616,15 @@ class User < ApplicationRecord
end
end
+ def owns_runner?(runner)
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336436') do
+ ci_owned_runners.exists?(runner.id)
+ end
+ end
+
def notification_email_for(notification_group)
# Return group-specific email address if present, otherwise return global notification email address
- notification_group&.notification_email_for(self) || notification_email
+ notification_group&.notification_email_for(self) || notification_email_or_default
end
def notification_settings_for(source, inherit: false)
@@ -1935,10 +1913,14 @@ class User < ApplicationRecord
def dismissed_callout?(feature_name:, ignore_dismissal_earlier_than: nil)
callout = callouts_by_feature_name[feature_name]
- return false unless callout
- return callout.dismissed_after?(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than
+ callout_dismissed?(callout, ignore_dismissal_earlier_than)
+ end
- true
+ def dismissed_callout_for_group?(feature_name:, group:, ignore_dismissal_earlier_than: nil)
+ source_feature_name = "#{feature_name}_#{group.id}"
+ callout = group_callouts_by_feature_name[source_feature_name]
+
+ callout_dismissed?(callout, ignore_dismissal_earlier_than)
end
# Load the current highest access by looking directly at the user's memberships
@@ -1962,6 +1944,11 @@ class User < ApplicationRecord
callouts.find_or_initialize_by(feature_name: ::UserCallout.feature_names[feature_name])
end
+ def find_or_initialize_group_callout(feature_name, group_id)
+ group_callouts
+ .find_or_initialize_by(feature_name: ::Users::GroupCallout.feature_names[feature_name], group_id: group_id)
+ end
+
def can_trigger_notifications?
confirmed? && !blocked? && !ghost?
end
@@ -2015,10 +2002,39 @@ class User < ApplicationRecord
private
+ def notification_email_verified
+ return if notification_email.blank? || temp_oauth_email?
+
+ errors.add(:notification_email, _("must be an email you have verified")) unless verified_emails.include?(notification_email_or_default)
+ end
+
+ def public_email_verified
+ return if public_email.blank?
+
+ errors.add(:public_email, _("must be an email you have verified")) unless verified_emails.include?(public_email)
+ end
+
+ def commit_email_verified
+ return if commit_email.blank?
+
+ errors.add(:commit_email, _("must be an email you have verified")) unless verified_emails.include?(commit_email_or_default)
+ end
+
+ def callout_dismissed?(callout, ignore_dismissal_earlier_than)
+ return false unless callout
+ return callout.dismissed_after?(ignore_dismissal_earlier_than) if ignore_dismissal_earlier_than
+
+ true
+ end
+
def callouts_by_feature_name
@callouts_by_feature_name ||= callouts.index_by(&:feature_name)
end
+ def group_callouts_by_feature_name
+ @group_callouts_by_feature_name ||= group_callouts.index_by(&:source_feature_name)
+ end
+
def authorized_groups_without_shared_membership
Group.from_union([
groups.select(Namespace.arel_table[Arel.star]),
@@ -2080,7 +2096,7 @@ class User < ApplicationRecord
def check_username_format
return if username.blank? || Mime::EXTENSION_LOOKUP.keys.none? { |type| username.end_with?(".#{type}") }
- errors.add(:username, _('ending with MIME type format is not allowed.'))
+ errors.add(:username, _('ending with a file extension is not allowed.'))
end
def groups_with_developer_maintainer_project_access
@@ -2090,9 +2106,12 @@ class User < ApplicationRecord
project_creation_levels << nil
end
- developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants
- ::Group.where(id: developer_groups_hierarchy.select(:id),
- project_creation_level: project_creation_levels)
+ if Feature.enabled?(:linear_user_groups_with_developer_maintainer_project_access, self, default_enabled: :yaml)
+ developer_groups.self_and_descendants.where(project_creation_level: project_creation_levels)
+ else
+ developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants
+ ::Group.where(id: developer_groups_hierarchy.select(:id), project_creation_level: project_creation_levels)
+ end
end
def no_recent_activity?
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 1172b2ee5e8..04bc29755f8 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class UserCallout < ApplicationRecord
- belongs_to :user
+ include Calloutable
enum feature_name: {
gke_cluster_integration: 1,
@@ -15,7 +15,7 @@ class UserCallout < ApplicationRecord
suggest_popover_dismissed: 9,
tabs_position_highlight: 10,
threat_monitoring_info: 11, # EE-only
- account_recovery_regular_check: 12, # EE-only
+ two_factor_auth_recovery_settings_check: 12, # EE-only
web_ide_alert_dismissed: 16, # no longer in use
active_user_count_threshold: 18, # EE-only
buy_pipeline_minutes_notification_dot: 19, # EE-only
@@ -39,13 +39,8 @@ class UserCallout < ApplicationRecord
terraform_notification_dismissed: 38
}
- validates :user, presence: true
validates :feature_name,
presence: true,
uniqueness: { scope: :user_id },
inclusion: { in: UserCallout.feature_names.keys }
-
- def dismissed_after?(dismissed_after)
- dismissed_at > dismissed_after
- end
end
diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb
index b3cca1e0cc0..c41cff67864 100644
--- a/app/models/user_detail.rb
+++ b/app/models/user_detail.rb
@@ -2,7 +2,8 @@
class UserDetail < ApplicationRecord
extend ::Gitlab::Utils::Override
- include CacheMarkdownField
+ include IgnorableColumns
+ ignore_columns %i[bio_html cached_markdown_version], remove_with: '13.6', remove_after: '2021-10-22'
belongs_to :user
@@ -13,20 +14,6 @@ class UserDetail < ApplicationRecord
before_save :prevent_nil_bio
- cache_markdown_field :bio
-
- def bio_html
- read_attribute(:bio_html) || bio
- end
-
- # For backward compatibility.
- # Older migrations (and their tests) reference the `User.migration_bot` where the `bio` attribute is set.
- # Here we disable writing the markdown cache when the `bio_html` column does not exist.
- override :invalidated_markdown_cache?
- def invalidated_markdown_cache?
- self.class.column_names.include?('bio_html') && super
- end
-
private
def prevent_nil_bio
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 2735e169b5f..337ae7125f3 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -20,7 +20,7 @@ class UserPreference < ApplicationRecord
less_than_or_equal_to: Gitlab::TabWidth::MAX
}
- enum experience_level: { novice: 0, experienced: 1 }
+ ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22'
default_value_for :tab_width, value: Gitlab::TabWidth::DEFAULT, allows_nil: false
default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
new file mode 100644
index 00000000000..540d1a1d242
--- /dev/null
+++ b/app/models/users/group_callout.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Users
+ class GroupCallout < ApplicationRecord
+ include Calloutable
+
+ self.table_name = 'user_group_callouts'
+
+ belongs_to :group
+
+ enum feature_name: {
+ invite_members_banner: 1
+ }
+
+ validates :group, presence: true
+ validates :feature_name,
+ presence: true,
+ uniqueness: { scope: [:user_id, :group_id] },
+ inclusion: { in: GroupCallout.feature_names.keys }
+
+ def source_feature_name
+ "#{feature_name}_#{group_id}"
+ end
+ end
+end
diff --git a/app/models/work_item/type.rb b/app/models/work_item/type.rb
index 16cb7a8be45..7038beadd62 100644
--- a/app/models/work_item/type.rb
+++ b/app/models/work_item/type.rb
@@ -9,14 +9,18 @@ class WorkItem::Type < ApplicationRecord
include CacheMarkdownField
+ # Base types need to exist on the DB on app startup
+ # This constant is used by the DB seeder
+ BASE_TYPES = {
+ issue: { name: 'Issue', icon_name: 'issue-type-issue', enum_value: 0 },
+ incident: { name: 'Incident', icon_name: 'issue-type-incident', enum_value: 1 },
+ test_case: { name: 'Test Case', icon_name: 'issue-type-test-case', enum_value: 2 }, ## EE-only
+ requirement: { name: 'Requirement', icon_name: 'issue-type-requirements', enum_value: 3 } ## EE-only
+ }.freeze
+
cache_markdown_field :description, pipeline: :single_line
- enum base_type: {
- issue: 0,
- incident: 1,
- test_case: 2, ## EE-only
- requirement: 3 ## EE-only
- }
+ enum base_type: BASE_TYPES.transform_values { |value| value[:enum_value] }
belongs_to :namespace, optional: true
has_many :work_items, class_name: 'Issue', foreign_key: :work_item_type_id, inverse_of: :work_item_type
@@ -30,6 +34,14 @@ class WorkItem::Type < ApplicationRecord
validates :name, length: { maximum: 255 }
validates :icon_name, length: { maximum: 255 }
+ def self.default_by_type(type)
+ find_by(namespace_id: nil, base_type: type)
+ end
+
+ def self.default_issue_type
+ default_by_type(:issue)
+ end
+
private
def strip_whitespace
diff --git a/app/models/zoom_meeting.rb b/app/models/zoom_meeting.rb
index f684f9e6fe0..dd230b46d4b 100644
--- a/app/models/zoom_meeting.rb
+++ b/app/models/zoom_meeting.rb
@@ -10,7 +10,7 @@ class ZoomMeeting < ApplicationRecord
validates :project, presence: true, unless: :importing?
validates :issue, presence: true, unless: :importing?
- validates :url, presence: true, length: { maximum: 255 }, 'gitlab/utils/zoom_url': true
+ validates :url, presence: true, length: { maximum: 255 }, 'gitlab/zoom_url': true
validates :issue, same_project_association: true, unless: :importing?
enum issue_status: {
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index de76b7b2b5b..43478cf36c2 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -5,9 +5,9 @@ module Ci
with_options scope: :subject, score: 0
condition(:locked, scope: :subject) { @subject.locked? }
- # rubocop: disable CodeReuse/ActiveRecord
- condition(:owned_runner) { @user.ci_owned_runners.exists?(@subject.id) }
- # rubocop: enable CodeReuse/ActiveRecord
+ condition(:owned_runner) do
+ @user.owns_runner?(@subject)
+ end
rule { anonymous }.prevent_all
diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb
index ba73b9a3782..98d1ab737ee 100644
--- a/app/policies/custom_emoji_policy.rb
+++ b/app/policies/custom_emoji_policy.rb
@@ -2,4 +2,14 @@
class CustomEmojiPolicy < BasePolicy
delegate { @subject.group }
+
+ condition(:author) { @subject.creator == @user }
+
+ rule { can?(:maintainer_access) }.policy do
+ enable :delete_custom_emoji
+ end
+
+ rule { author & can?(:create_custom_emoji) }.policy do
+ enable :delete_custom_emoji
+ end
end
diff --git a/app/policies/customer_relations/contact_policy.rb b/app/policies/customer_relations/contact_policy.rb
new file mode 100644
index 00000000000..8367649b50c
--- /dev/null
+++ b/app/policies/customer_relations/contact_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module CustomerRelations
+ class ContactPolicy < BasePolicy
+ delegate { @subject.group }
+ end
+end
diff --git a/app/policies/customer_relations/organization_policy.rb b/app/policies/customer_relations/organization_policy.rb
new file mode 100644
index 00000000000..7bf8d6ff4cb
--- /dev/null
+++ b/app/policies/customer_relations/organization_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module CustomerRelations
+ class OrganizationPolicy < BasePolicy
+ delegate { @subject.group }
+ end
+end
diff --git a/app/policies/dependency_proxy/blob_policy.rb b/app/policies/dependency_proxy/blob_policy.rb
new file mode 100644
index 00000000000..42e023952d0
--- /dev/null
+++ b/app/policies/dependency_proxy/blob_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module DependencyProxy
+ class BlobPolicy < BasePolicy
+ delegate { @subject.group }
+ end
+end
diff --git a/app/policies/dependency_proxy/group_setting_policy.rb b/app/policies/dependency_proxy/group_setting_policy.rb
new file mode 100644
index 00000000000..71de3cf93bd
--- /dev/null
+++ b/app/policies/dependency_proxy/group_setting_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module DependencyProxy
+ class GroupSettingPolicy < BasePolicy
+ delegate { @subject.group }
+ end
+end
diff --git a/app/policies/dependency_proxy/image_ttl_group_policy_policy.rb b/app/policies/dependency_proxy/image_ttl_group_policy_policy.rb
new file mode 100644
index 00000000000..cf7e1ded137
--- /dev/null
+++ b/app/policies/dependency_proxy/image_ttl_group_policy_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module DependencyProxy
+ class ImageTtlGroupPolicyPolicy < BasePolicy
+ delegate { @subject.group }
+ end
+end
diff --git a/app/policies/dependency_proxy/manifest_policy.rb b/app/policies/dependency_proxy/manifest_policy.rb
new file mode 100644
index 00000000000..f2e91e45327
--- /dev/null
+++ b/app/policies/dependency_proxy/manifest_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module DependencyProxy
+ class ManifestPolicy < BasePolicy
+ delegate { @subject.group }
+ end
+end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 1d0aa54c1c0..7abffd2c352 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -89,6 +89,7 @@ class GroupPolicy < BasePolicy
rule { guest }.policy do
enable :read_group
enable :upload_file
+ enable :guest_access
end
rule { admin }.policy do
@@ -111,8 +112,13 @@ class GroupPolicy < BasePolicy
enable :read_issue_board
enable :read_group_member
enable :read_custom_emoji
+ enable :read_counts
+ enable :read_organization
+ enable :read_contact
end
+ rule { ~public_group & ~has_access }.prevent :read_counts
+
rule { ~can?(:read_group) }.policy do
prevent :read_design_activity
end
@@ -127,6 +133,7 @@ class GroupPolicy < BasePolicy
enable :create_custom_emoji
enable :create_package
enable :create_package_settings
+ enable :developer_access
end
rule { reporter }.policy do
@@ -140,6 +147,7 @@ class GroupPolicy < BasePolicy
enable :read_prometheus
enable :read_package
enable :read_package_settings
+ enable :admin_organization
end
rule { maintainer }.policy do
@@ -155,6 +163,7 @@ class GroupPolicy < BasePolicy
enable :read_deploy_token
enable :create_jira_connect_subscription
enable :update_runners_registration_token
+ enable :maintainer_access
end
rule { owner }.policy do
@@ -170,6 +179,7 @@ class GroupPolicy < BasePolicy
enable :update_default_branch_protection
enable :create_deploy_token
enable :destroy_deploy_token
+ enable :owner_access
end
rule { can?(:read_nested_project_resources) }.policy do
@@ -223,8 +233,9 @@ class GroupPolicy < BasePolicy
rule { dependency_proxy_access_allowed & dependency_proxy_available }
.enable :read_dependency_proxy
- rule { developer & dependency_proxy_available }
- .enable :admin_dependency_proxy
+ rule { developer & dependency_proxy_available }.policy do
+ enable :admin_dependency_proxy
+ end
rule { can?(:admin_group) & resource_access_token_feature_available }.policy do
enable :read_resource_access_tokens
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index 74bed6b6c4e..575e532c615 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -69,6 +69,14 @@ class IssuePolicy < IssuablePolicy
rule { persisted & can?(:admin_issue) }.policy do
enable :set_issue_metadata
end
+
+ rule { can?(:set_issue_metadata) }.policy do
+ enable :set_confidentiality
+ end
+
+ rule { ~persisted & can?(:create_issue) }.policy do
+ enable :set_confidentiality
+ end
end
IssuePolicy.prepend_mod_with('IssuePolicy')
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 067f0f6a9d2..018c061af9f 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -25,6 +25,7 @@ class UserPolicy < BasePolicy
enable :update_user_status
enable :read_user_personal_access_tokens
enable :read_group_count
+ enable :read_user_groups
end
rule { default }.enable :read_user_profile
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index 384cb3285fc..06ed6791bb7 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -12,11 +12,11 @@ module Ci
erased_by.name if erased_by_user?
end
- def status_title
+ def status_title(status = detailed_status)
if auto_canceled?
"Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
else
- tooltip_for_badge
+ tooltip_for_badge(status)
end
end
@@ -41,8 +41,8 @@ module Ci
private
- def tooltip_for_badge
- detailed_status.badge_tooltip.capitalize
+ def tooltip_for_badge(status)
+ status.badge_tooltip.capitalize
end
def detailed_status
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 0baee614568..b0066e2d7f0 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -33,7 +33,7 @@ module Ci
end
def runner_variables
- if Feature.enabled?(:variable_inside_variable, project)
+ if Feature.enabled?(:variable_inside_variable, project, default_enabled: :yaml)
variables.sort_and_expand_all(project, keep_undefined: true).to_runner_variables
else
variables.to_runner_variables
diff --git a/app/presenters/ci/legacy_stage_presenter.rb b/app/presenters/ci/legacy_stage_presenter.rb
index 56e268cff9f..d5c21baba28 100644
--- a/app/presenters/ci/legacy_stage_presenter.rb
+++ b/app/presenters/ci/legacy_stage_presenter.rb
@@ -15,18 +15,9 @@ module Ci
private
def preload_statuses(statuses)
- loaded_statuses = statuses.load
- statuses.tap do |statuses|
- # rubocop: disable CodeReuse/ActiveRecord
- ActiveRecord::Associations::Preloader.new.preload(preloadable_statuses(loaded_statuses), %w[tags job_artifacts_archive metadata])
- # rubocop: enable CodeReuse/ActiveRecord
- end
- end
+ Preloaders::CommitStatusPreloader.new(statuses).execute(Ci::StagePresenter::PRELOADED_RELATIONS)
- def preloadable_statuses(statuses)
- statuses.reject do |status|
- status.instance_of?(::GenericCommitStatus) || status.instance_of?(::Ci::Bridge)
- end
+ statuses
end
end
end
diff --git a/app/presenters/ci/stage_presenter.rb b/app/presenters/ci/stage_presenter.rb
index 9ec3f8d153a..21bda86cded 100644
--- a/app/presenters/ci/stage_presenter.rb
+++ b/app/presenters/ci/stage_presenter.rb
@@ -4,6 +4,8 @@ module Ci
class StagePresenter < Gitlab::View::Presenter::Delegated
presents :stage
+ PRELOADED_RELATIONS = [:pipeline, :metadata, :tags, :job_artifacts_archive, :downstream_pipeline].freeze
+
def latest_ordered_statuses
preload_statuses(stage.statuses.latest_ordered)
end
@@ -15,18 +17,9 @@ module Ci
private
def preload_statuses(statuses)
- loaded_statuses = statuses.load
- statuses.tap do |statuses|
- # rubocop: disable CodeReuse/ActiveRecord
- ActiveRecord::Associations::Preloader.new.preload(preloadable_statuses(loaded_statuses), %w[pipeline tags job_artifacts_archive metadata])
- # rubocop: enable CodeReuse/ActiveRecord
- end
- end
+ Preloaders::CommitStatusPreloader.new(statuses).execute(PRELOADED_RELATIONS)
- def preloadable_statuses(statuses)
- statuses.reject do |status|
- status.instance_of?(::GenericCommitStatus) || status.instance_of?(::Ci::Bridge)
- end
+ statuses
end
end
end
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 5f5bbf13f92..3c39470b730 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -27,7 +27,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
user_blocked: 'The user who created this job is blocked',
ci_quota_exceeded: 'No more CI minutes available',
no_matching_runner: 'No matching runner available',
- trace_size_exceeded: 'The job log size limit was reached'
+ trace_size_exceeded: 'The job log size limit was reached',
+ builds_disabled: 'The CI/CD is disabled for this project'
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/packages/helm/index_presenter.rb b/app/presenters/packages/helm/index_presenter.rb
index a6cfc61c94d..e85a4f248cf 100644
--- a/app/presenters/packages/helm/index_presenter.rb
+++ b/app/presenters/packages/helm/index_presenter.rb
@@ -8,11 +8,12 @@ module Packages
API_VERSION = 'v1'
CHANNEL = 'channel'
INDEX_YAML_SUFFIX = "/#{CHANNEL}/index.yaml"
+ EMPTY_HASH = {}.freeze
- def initialize(project, project_id_param, package_files)
- @project = project
+ def initialize(project_id_param, channel, packages)
@project_id_param = project_id_param
- @package_files = package_files
+ @channel = channel
+ @packages = packages
end
def api_version
@@ -20,10 +21,12 @@ module Packages
end
def entries
- files = @package_files.preload_helm_file_metadata
+ return EMPTY_HASH unless @channel.present?
+
result = Hash.new { |h, k| h[k] = [] }
- files.find_each do |package_file|
+ # this .each is safe as we have max 300 objects
+ most_recent_package_files.each do |package_file|
name = package_file.helm_metadata['name']
result[name] << package_file.helm_metadata.merge({
'created' => package_file.created_at.utc.strftime('%Y-%m-%dT%H:%M:%S.%NZ'),
@@ -48,6 +51,16 @@ module Packages
'contextPath' => path.delete_suffix(INDEX_YAML_SUFFIX)
}
end
+
+ private
+
+ def most_recent_package_files
+ ::Packages::PackageFile.most_recent_for(
+ @packages,
+ extra_join: :helm_file_metadatum,
+ extra_where: { packages_helm_file_metadata: { channel: @channel } }
+ ).preload_helm_file_metadata
+ end
end
end
end
diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb
index 4e147b4739e..b9595eb6647 100644
--- a/app/presenters/packages/npm/package_presenter.rb
+++ b/app/presenters/packages/npm/package_presenter.rb
@@ -7,8 +7,6 @@ module Packages
attr_reader :name, :packages
- NPM_VALID_DEPENDENCY_TYPES = %i[dependencies devDependencies bundleDependencies peerDependencies].freeze
-
def initialize(name, packages)
@name = name
@packages = packages
@@ -17,12 +15,16 @@ module Packages
def versions
package_versions = {}
- packages.each do |package|
- package_file = package.package_files.last
+ packages.each_batch do |relation|
+ relation.including_dependency_links
+ .preload_files
+ .each do |package|
+ package_file = package.package_files.last
- next unless package_file
+ next unless package_file
- package_versions[package.version] = build_package_version(package, package_file)
+ package_versions[package.version] = build_package_version(package, package_file)
+ end
end
package_versions
@@ -59,11 +61,8 @@ module Packages
def build_package_dependencies(package)
dependencies = Hash.new { |h, key| h[key] = {} }
- dependency_links = package.dependency_links
- .with_dependency_type(NPM_VALID_DEPENDENCY_TYPES)
- .includes_dependency
- dependency_links.find_each do |dependency_link|
+ package.dependency_links.each do |dependency_link|
dependency = dependency_link.dependency
dependencies[dependency_link.dependency_type][dependency.name] = dependency.version_pattern
end
@@ -72,13 +71,13 @@ module Packages
end
def sorted_versions
- versions = packages.map(&:version).compact
+ versions = packages.pluck_versions.compact
VersionSorter.sort(versions)
end
def package_tags
Packages::Tag.for_packages(packages)
- .preload_package
+ .preload_package
end
end
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 80a8ee5cb3c..066f4786cff 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -431,22 +431,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
private
def integrations_anchor_data
- experiment(:repo_integrations_link, project: project) do |e|
- e.exclude! unless can?(current_user, :admin_project, project)
-
- e.use {} # nil control
- e.try do
- label = statistic_icon('settings') + _('Configure Integrations')
- AnchorData.new(false, label, project_settings_integrations_path(project), nil, nil, nil, {
- 'track-event': 'click',
- 'track-experiment': e.name
- })
- end
-
- e.run # call run so the return value will be the AnchorData (or nil)
+ return unless can?(current_user, :admin_project, project)
- e.track(:view, value: project.id) # track an event for the view, with project id
- end
+ label = statistic_icon('settings') + _('Configure Integrations')
+ AnchorData.new(false, label, project_settings_integrations_path(project), nil, nil, nil)
end
def cicd_missing?
diff --git a/app/presenters/projects/import_export/project_export_presenter.rb b/app/presenters/projects/import_export/project_export_presenter.rb
index 611294ddfd8..f56760b55df 100644
--- a/app/presenters/projects/import_export/project_export_presenter.rb
+++ b/app/presenters/projects/import_export/project_export_presenter.rb
@@ -34,7 +34,6 @@ module Projects
# We need `.connected_to_user` here otherwise when a group has an
# invitee, it would make the following query return 0 rows since a NULL
# user_id would be present in the subquery
- # See http://stackoverflow.com/questions/129077/not-in-clause-and-null-values
non_null_user_ids = project.project_members.connected_to_user.select(:user_id)
GroupMembersFinder.new(project.group).execute.where.not(user_id: non_null_user_ids)
end
diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb
index 0003a13a7bc..ab8fc0f905b 100644
--- a/app/presenters/snippet_blob_presenter.rb
+++ b/app/presenters/snippet_blob_presenter.rb
@@ -17,6 +17,10 @@ class SnippetBlobPresenter < BlobPresenter
snippet_blob_raw_route
end
+ def raw_directory
+ raw_path.rpartition("/").first + "/"
+ end
+
def raw_plain_data
blob.data unless blob.binary?
end
@@ -33,7 +37,7 @@ class SnippetBlobPresenter < BlobPresenter
def render_rich_partial
renderer.render("projects/blob/viewers/_#{blob.rich_viewer.partial_name}",
- locals: { viewer: blob.rich_viewer, blob: blob, blob_raw_path: raw_path, blob_raw_url: raw_url },
+ locals: { viewer: blob.rich_viewer, blob: blob, blob_raw_path: raw_path, blob_raw_url: raw_url, parent_dir_raw_path: raw_directory },
layout: false)
end
diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb
index 619ca0b5f82..0ca6e7b40d9 100644
--- a/app/serializers/group_child_entity.rb
+++ b/app/serializers/group_child_entity.rb
@@ -37,9 +37,13 @@ class GroupChildEntity < Grape::Entity
if: lambda { |_instance, _options| project? }
# Group only attributes
- expose :children_count, :parent_id, :project_count, :subgroup_count,
+ expose :children_count, :parent_id,
unless: lambda { |_instance, _options| project? }
+ expose :subgroup_count, if: lambda { |group| access_group_counts?(group) }
+
+ expose :project_count, if: lambda { |group| access_group_counts?(group) }
+
expose :leave_path, unless: lambda { |_instance, _options| project? } do |instance|
leave_group_members_path(instance)
end
@@ -52,10 +56,6 @@ class GroupChildEntity < Grape::Entity
end
end
- expose :number_projects_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
- number_with_delimiter(instance.project_count)
- end
-
expose :number_users_with_delimiter, unless: lambda { |_instance, _options| project? } do |instance|
number_with_delimiter(instance.member_count)
end
@@ -66,6 +66,10 @@ class GroupChildEntity < Grape::Entity
private
+ def access_group_counts?(group)
+ !project? && can?(request.current_user, :read_counts, group)
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def membership
return unless request.current_user
diff --git a/app/serializers/group_entity.rb b/app/serializers/group_entity.rb
index 0e1bc9a6b3d..66919c49320 100644
--- a/app/serializers/group_entity.rb
+++ b/app/serializers/group_entity.rb
@@ -40,10 +40,6 @@ class GroupEntity < Grape::Entity
GroupsFinder.new(request.current_user, parent: group).execute.any?
end
- expose :number_projects_with_delimiter do |group|
- number_with_delimiter(GroupProjectsFinder.new(group: group, current_user: request.current_user).execute.count)
- end
-
expose :number_users_with_delimiter do |group|
number_with_delimiter(group.users.count)
end
diff --git a/app/serializers/issuable_sidebar_extras_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb
index 77f2e34fa5d..68c71cd5cf3 100644
--- a/app/serializers/issuable_sidebar_extras_entity.rb
+++ b/app/serializers/issuable_sidebar_extras_entity.rb
@@ -3,23 +3,6 @@
class IssuableSidebarExtrasEntity < Grape::Entity
include RequestAwareEntity
include TimeTrackableEntity
- include NotificationsHelper
-
- expose :participants, using: ::API::Entities::UserBasic do |issuable|
- issuable.participants(request.current_user)
- end
-
- expose :project_emails_disabled do |issuable|
- issuable.project.emails_disabled?
- end
-
- expose :subscribe_disabled_description do |issuable|
- notification_description(:owner_disabled)
- end
-
- expose :subscribed do |issuable|
- issuable.subscribed?(request.current_user, issuable.project)
- end
expose :assignees, using: ::API::Entities::UserBasic
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index a2683647c72..bc734465750 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -45,7 +45,12 @@ module Auth
token.expire_time = token_expire_at
token[:access] = names.map do |name|
- { type: 'repository', name: name, actions: actions }
+ {
+ type: 'repository',
+ name: name,
+ actions: actions,
+ migration_eligible: migration_eligible(repository_path: name)
+ }.compact
end
token.encoded
@@ -119,13 +124,20 @@ module Auth
type: type,
name: path.to_s,
actions: authorized_actions,
- migration_eligible: migration_eligible(requested_project, authorized_actions)
+ migration_eligible: self.class.migration_eligible(project: requested_project)
}.compact
end
- def migration_eligible(project, actions)
+ def self.migration_eligible(project: nil, repository_path: nil)
return unless Feature.enabled?(:container_registry_migration_phase1)
+ # project has precedence over repository_path. If only the latter is provided, we find the corresponding Project.
+ unless project
+ return unless repository_path
+
+ project = ContainerRegistry::Path.new(repository_path).repository_project
+ end
+
# The migration process will start by allowing only specific test and gitlab-org projects using the
# `container_registry_migration_phase1_allow` FF. We'll then move on to a percentage rollout using this same FF.
# To remove the risk of impacting enterprise customers that rely heavily on the registry during the percentage
diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb
index 8492b3ce92c..190d159e7f1 100644
--- a/app/services/base_container_service.rb
+++ b/app/services/base_container_service.rb
@@ -2,12 +2,12 @@
# Base class, scoped by container (project or group).
#
-# New or existing services which only require project as a container
-# should subclass BaseProjectService.
+# New or existing services which only require a project or group container
+# should subclass BaseProjectService or BaseGroupService.
#
-# If you require a different but specific, non-polymorphic container (such
-# as group), consider creating a new subclass such as BaseGroupService,
-# and update the related comment at the top of the original BaseService.
+# If you require a different but specific, non-polymorphic container
+# consider creating a new subclass, and update the related comment at
+# the top of the original BaseService.
class BaseContainerService
include BaseServiceUtility
diff --git a/app/services/base_group_service.rb b/app/services/base_group_service.rb
new file mode 100644
index 00000000000..c95b6f5af60
--- /dev/null
+++ b/app/services/base_group_service.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+# Base class, scoped by group
+class BaseGroupService < ::BaseContainerService # rubocop:disable Gitlab/NamespacedClass
+ attr_accessor :group
+
+ def initialize(group:, current_user: nil, params: {})
+ super(container: group, current_user: current_user, params: params)
+
+ @group = group
+ end
+end
diff --git a/app/services/base_service.rb b/app/services/base_service.rb
index 3030287e035..275ebcc7bcd 100644
--- a/app/services/base_service.rb
+++ b/app/services/base_service.rb
@@ -10,6 +10,7 @@
#
# - BaseContainerService for services scoped by container (project or group)
# - BaseProjectService for services scoped to projects
+# - BaseGroupService for services scoped to groups
#
# or, create a new base class and update this comment.
class BaseService
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 8806e6788ff..9a3e3bc3bdb 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -9,6 +9,14 @@ module Boards
IssuesFinder.valid_params
end
+ # It is a class method because we cannot apply it
+ # prior to knowing how many items should be fetched for a list.
+ def self.initialize_relative_positions(board, current_user, issues)
+ if Gitlab::Database.read_write? && !board.disabled_for?(current_user)
+ Issue.move_nulls_to_end(issues)
+ end
+ end
+
private
def order(items)
diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb
index f717dd0862c..9101ae91967 100644
--- a/app/services/ci/after_requeue_job_service.rb
+++ b/app/services/ci/after_requeue_job_service.rb
@@ -10,16 +10,9 @@ module Ci
private
def process_subsequent_jobs(processable)
- if Feature.enabled?(:ci_same_stage_job_needs, processable.project, default_enabled: :yaml)
- (stage_dependent_jobs(processable) | needs_dependent_jobs(processable))
- .each do |processable|
- process(processable)
- end
- else
- skipped_jobs(processable).after_stage(processable.stage_idx)
- .find_each do |job|
- process(job)
- end
+ (stage_dependent_jobs(processable) | needs_dependent_jobs(processable))
+ .each do |processable|
+ process(processable)
end
end
diff --git a/app/services/ci/archive_trace_service.rb b/app/services/ci/archive_trace_service.rb
index bc3219fbd79..995b58c6882 100644
--- a/app/services/ci/archive_trace_service.rb
+++ b/app/services/ci/archive_trace_service.rb
@@ -3,6 +3,13 @@
module Ci
class ArchiveTraceService
def execute(job, worker_name:)
+ unless job.trace.can_attempt_archival_now?
+ Sidekiq.logger.warn(class: worker_name,
+ message: job.trace.archival_attempts_message,
+ job_id: job.id)
+ return
+ end
+
# TODO: Remove this logging once we confirmed new live trace architecture is functional.
# See https://gitlab.com/gitlab-com/gl-infra/infrastructure/issues/4667.
unless job.has_live_trace?
@@ -25,6 +32,8 @@ module Ci
rescue ::Gitlab::Ci::Trace::AlreadyArchivedError
# It's already archived, thus we can safely ignore this exception.
rescue StandardError => e
+ job.trace.increment_archival_attempts!
+
# Tracks this error with application logs, Sentry, and Prometheus.
# If `archive!` keeps failing for over a week, that could incur data loss.
# (See more https://docs.gitlab.com/ee/administration/job_logs.html#new-incremental-logging-architecture)
diff --git a/app/services/ci/drop_pipeline_service.rb b/app/services/ci/drop_pipeline_service.rb
index 16d3abcbfa0..5772ab8f29c 100644
--- a/app/services/ci/drop_pipeline_service.rb
+++ b/app/services/ci/drop_pipeline_service.rb
@@ -2,8 +2,7 @@
module Ci
class DropPipelineService
- PRELOADED_COMMIT_STATUS_RELATIONS = [:project, :pipeline].freeze
- PRELOADED_CI_BUILD_RELATIONS = [:metadata, :deployment, :taggings].freeze
+ PRELOADED_RELATIONS = [:project, :pipeline, :metadata, :deployment, :taggings].freeze
# execute service asynchronously for each cancelable pipeline
def execute_async_for_all(pipelines, failure_reason, context_user)
@@ -30,11 +29,8 @@ module Ci
private
- # rubocop: disable CodeReuse/ActiveRecord
def preload_associations_for_drop(commit_status_batch)
- ActiveRecord::Associations::Preloader.new.preload(commit_status_batch, PRELOADED_COMMIT_STATUS_RELATIONS)
- ActiveRecord::Associations::Preloader.new.preload(commit_status_batch.select { |job| job.is_a?(Ci::Build) }, PRELOADED_CI_BUILD_RELATIONS)
+ Preloaders::CommitStatusPreloader.new(commit_status_batch).execute(PRELOADED_RELATIONS)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/ci/external_pull_requests/create_pipeline_service.rb b/app/services/ci/external_pull_requests/create_pipeline_service.rb
index 83499524a8e..dd93ca4708e 100644
--- a/app/services/ci/external_pull_requests/create_pipeline_service.rb
+++ b/app/services/ci/external_pull_requests/create_pipeline_service.rb
@@ -16,8 +16,14 @@ module Ci
private
def create_pipeline_for(pull_request)
- Ci::CreatePipelineService.new(project, current_user, create_params(pull_request))
- .execute(:external_pull_request_event, external_pull_request: pull_request)
+ if ::Feature.enabled?(:ci_create_external_pr_pipeline_async, project, default_enabled: :yaml)
+ Ci::ExternalPullRequests::CreatePipelineWorker.perform_async(
+ project.id, current_user.id, pull_request.id
+ )
+ else
+ Ci::CreatePipelineService.new(project, current_user, create_params(pull_request))
+ .execute(:external_pull_request_event, external_pull_request: pull_request)
+ end
end
def create_params(pull_request)
diff --git a/app/services/ci/pipeline_schedules/calculate_next_run_service.rb b/app/services/ci/pipeline_schedules/calculate_next_run_service.rb
index 9c8f6b47288..6c8ccb017e9 100644
--- a/app/services/ci/pipeline_schedules/calculate_next_run_service.rb
+++ b/app/services/ci/pipeline_schedules/calculate_next_run_service.rb
@@ -8,7 +8,6 @@ module Ci
def execute(schedule, fallback_method:)
@schedule = schedule
- return fallback_method.call unless ::Feature.enabled?(:ci_daily_limit_for_pipeline_schedules, project, default_enabled: :yaml)
return fallback_method.call unless plan_cron&.cron_valid?
now = Time.zone.now
diff --git a/app/services/ci/pipelines/add_job_service.rb b/app/services/ci/pipelines/add_job_service.rb
index 41f9903e48c..53536b6fdf9 100644
--- a/app/services/ci/pipelines/add_job_service.rb
+++ b/app/services/ci/pipelines/add_job_service.rb
@@ -21,14 +21,14 @@ module Ci
Ci::Pipeline.transaction do
yield(job)
- job.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, pipeline.project, default_enabled: :yaml)
+ job.update_older_statuses_retried!
end
end
else
Ci::Pipeline.transaction do
yield(job)
- job.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, pipeline.project, default_enabled: :yaml)
+ job.update_older_statuses_retried!
end
end
diff --git a/app/services/ci/queue/build_queue_service.rb b/app/services/ci/queue/build_queue_service.rb
index 99408d529b2..3276c427923 100644
--- a/app/services/ci/queue/build_queue_service.rb
+++ b/app/services/ci/queue/build_queue_service.rb
@@ -24,26 +24,30 @@ module Ci
# rubocop:disable CodeReuse/ActiveRecord
def builds_for_group_runner
- # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
- groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces)
+ if strategy.use_denormalized_namespace_traversal_ids?
+ strategy.builds_for_group_runner
+ else
+ # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
+ groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces)
- hierarchy_groups = Gitlab::ObjectHierarchy
- .new(groups)
- .base_and_descendants
+ hierarchy_groups = Gitlab::ObjectHierarchy
+ .new(groups)
+ .base_and_descendants
- projects = Project.where(namespace_id: hierarchy_groups)
- .with_group_runners_enabled
- .with_builds_enabled
- .without_deleted
+ projects = Project.where(namespace_id: hierarchy_groups)
+ .with_group_runners_enabled
+ .with_builds_enabled
+ .without_deleted
- relation = new_builds.where(project: projects)
+ relation = new_builds.where(project: projects)
- order(relation)
+ order(relation)
+ end
end
def builds_for_project_runner
relation = new_builds
- .where(project: runner.projects.without_deleted.with_builds_enabled)
+ .where(project: runner_projects_relation)
order(relation)
end
@@ -83,6 +87,14 @@ module Ci
end
end
end
+
+ def runner_projects_relation
+ if ::Feature.enabled?(:ci_pending_builds_project_runners_decoupling, runner, default_enabled: :yaml)
+ runner.runner_projects.select(:project_id)
+ else
+ runner.projects.without_deleted.with_builds_enabled
+ end
+ end
end
end
end
diff --git a/app/services/ci/queue/builds_table_strategy.rb b/app/services/ci/queue/builds_table_strategy.rb
index d0a343cb9d4..ac449a5289e 100644
--- a/app/services/ci/queue/builds_table_strategy.rb
+++ b/app/services/ci/queue/builds_table_strategy.rb
@@ -31,6 +31,10 @@ module Ci
end
end
+ def builds_for_group_runner
+ raise NotImplementedError
+ end
+
def builds_matching_tag_ids(relation, ids)
# pick builds that does not have other tags than runner's one
relation.matches_tag_ids(ids)
@@ -61,6 +65,10 @@ module Ci
false
end
+ def use_denormalized_namespace_traversal_ids?
+ false
+ end
+
private
def running_builds_for_shared_runners
diff --git a/app/services/ci/queue/pending_builds_strategy.rb b/app/services/ci/queue/pending_builds_strategy.rb
index efe3a981d3a..7a913e47df4 100644
--- a/app/services/ci/queue/pending_builds_strategy.rb
+++ b/app/services/ci/queue/pending_builds_strategy.rb
@@ -16,12 +16,26 @@ module Ci
builds_ordered_for_shared_runners(shared_builds)
end
+ def builds_for_group_runner
+ return new_builds.none if runner.namespace_ids.empty?
+
+ new_builds.where('ci_pending_builds.namespace_traversal_ids && ARRAY[?]::int[]', runner.namespace_ids)
+ end
+
def builds_matching_tag_ids(relation, ids)
- relation.merge(CommitStatus.matches_tag_ids(ids, table: 'ci_pending_builds', column: 'build_id'))
+ if ::Feature.enabled?(:ci_queueing_denormalize_tags_information, runner, default_enabled: :yaml)
+ relation.for_tags(runner.tags_ids)
+ else
+ relation.merge(CommitStatus.matches_tag_ids(ids, table: 'ci_pending_builds', column: 'build_id'))
+ end
end
def builds_with_any_tags(relation)
- relation.merge(CommitStatus.with_any_tags(table: 'ci_pending_builds', column: 'build_id'))
+ if ::Feature.enabled?(:ci_queueing_denormalize_tags_information, runner, default_enabled: :yaml)
+ relation.where('cardinality(tag_ids) > 0')
+ else
+ relation.merge(CommitStatus.with_any_tags(table: 'ci_pending_builds', column: 'build_id'))
+ end
end
def order(relation)
@@ -44,6 +58,10 @@ module Ci
::Feature.enabled?(:ci_queueing_denormalize_ci_minutes_information, runner, type: :development, default_enabled: :yaml)
end
+ def use_denormalized_namespace_traversal_ids?
+ ::Feature.enabled?(:ci_queueing_denormalize_namespace_traversal_ids, runner, type: :development, default_enabled: :yaml)
+ end
+
private
def builds_available_for_shared_runners
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index dc046e1d164..c46ddd22558 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -103,40 +103,42 @@ module Ci
# rubocop: disable CodeReuse/ActiveRecord
def each_build(params, &blk)
- queue = ::Ci::Queue::BuildQueueService.new(runner)
-
- builds = begin
- if runner.instance_type?
- queue.builds_for_shared_runner
- elsif runner.group_type?
- queue.builds_for_group_runner
- else
- queue.builds_for_project_runner
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339429') do
+ queue = ::Ci::Queue::BuildQueueService.new(runner)
+
+ builds = begin
+ if runner.instance_type?
+ queue.builds_for_shared_runner
+ elsif runner.group_type?
+ queue.builds_for_group_runner
+ else
+ queue.builds_for_project_runner
+ end
end
- end
- if runner.ref_protected?
- builds = queue.builds_for_protected_runner(builds)
- end
+ if runner.ref_protected?
+ builds = queue.builds_for_protected_runner(builds)
+ end
- # pick builds that does not have other tags than runner's one
- builds = queue.builds_matching_tag_ids(builds, runner.tags.ids)
+ # pick builds that does not have other tags than runner's one
+ builds = queue.builds_matching_tag_ids(builds, runner.tags.ids)
- # pick builds that have at least one tag
- unless runner.run_untagged?
- builds = queue.builds_with_any_tags(builds)
- end
+ # pick builds that have at least one tag
+ unless runner.run_untagged?
+ builds = queue.builds_with_any_tags(builds)
+ end
- # pick builds that older than specified age
- if params.key?(:job_age)
- builds = queue.builds_queued_before(builds, params[:job_age].seconds.ago)
- end
+ # pick builds that older than specified age
+ if params.key?(:job_age)
+ builds = queue.builds_queued_before(builds, params[:job_age].seconds.ago)
+ end
- build_ids = retrieve_queue(-> { queue.execute(builds) })
+ build_ids = retrieve_queue(-> { queue.execute(builds) })
- @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type)
+ @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type)
- build_ids.each { |build_id| yield Ci::Build.find(build_id) }
+ build_ids.each { |build_id| yield Ci::Build.find(build_id) }
+ end
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -269,6 +271,15 @@ module Ci
missing_dependency_failure: -> (build, _) { !build.has_valid_build_dependencies? },
runner_unsupported: -> (build, params) { !build.supported_runner?(params.dig(:info, :features)) },
archived_failure: -> (build, _) { build.archived? }
+ }.merge(builds_enabled_checks)
+ end
+
+ def builds_enabled_checks
+ return {} unless ::Feature.enabled?(:ci_queueing_builds_enabled_checks, runner, default_enabled: :yaml)
+
+ {
+ project_deleted: -> (build, _) { build.project.pending_delete? },
+ builds_disabled: -> (build, _) { !build.project.builds_enabled? }
}
end
end
diff --git a/app/services/ci/stuck_builds/drop_helpers.rb b/app/services/ci/stuck_builds/drop_helpers.rb
new file mode 100644
index 00000000000..f79b805c23d
--- /dev/null
+++ b/app/services/ci/stuck_builds/drop_helpers.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Ci
+ module StuckBuilds
+ module DropHelpers
+ def drop(builds, failure_reason:)
+ fetch(builds) do |build|
+ drop_build :outdated, build, failure_reason
+ end
+ end
+
+ def drop_stuck(builds, failure_reason:)
+ fetch(builds) do |build|
+ break unless build.stuck?
+
+ drop_build :stuck, build, failure_reason
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def fetch(builds)
+ loop do
+ jobs = builds.includes(:tags, :runner, project: [:namespace, :route])
+ .limit(100)
+ .to_a
+
+ break if jobs.empty?
+
+ jobs.each do |job|
+ Gitlab::ApplicationContext.with_context(project: job.project) { yield(job) }
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def drop_build(type, build, reason)
+ Gitlab::AppLogger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{build.status}, failure_reason: #{reason})"
+ Gitlab::OptimisticLocking.retry_lock(build, 3, name: 'stuck_ci_jobs_worker_drop_build') do |b|
+ b.drop(reason)
+ end
+ rescue StandardError => ex
+ build.doom!
+
+ track_exception_for_build(ex, build)
+ end
+
+ def track_exception_for_build(ex, build)
+ Gitlab::ErrorTracking.track_exception(ex,
+ build_id: build.id,
+ build_name: build.name,
+ build_stage: build.stage,
+ pipeline_id: build.pipeline_id,
+ project_id: build.project_id
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/ci/stuck_builds/drop_service.rb b/app/services/ci/stuck_builds/drop_service.rb
new file mode 100644
index 00000000000..3fee9a94381
--- /dev/null
+++ b/app/services/ci/stuck_builds/drop_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Ci
+ module StuckBuilds
+ class DropService
+ include DropHelpers
+
+ BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour
+ BUILD_PENDING_OUTDATED_TIMEOUT = 1.day
+ BUILD_SCHEDULED_OUTDATED_TIMEOUT = 1.hour
+ BUILD_PENDING_STUCK_TIMEOUT = 1.hour
+ BUILD_LOOKBACK = 5.days
+
+ def execute
+ Gitlab::AppLogger.info "#{self.class}: Cleaning stuck builds"
+
+ drop(running_timed_out_builds, failure_reason: :stuck_or_timeout_failure)
+
+ drop(
+ pending_builds(BUILD_PENDING_OUTDATED_TIMEOUT.ago),
+ failure_reason: :stuck_or_timeout_failure
+ )
+
+ drop(scheduled_timed_out_builds, failure_reason: :stale_schedule)
+
+ drop_stuck(
+ pending_builds(BUILD_PENDING_STUCK_TIMEOUT.ago),
+ failure_reason: :stuck_or_timeout_failure
+ )
+ end
+
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ # We're adding the ordering clause by `created_at` and `project_id`
+ # because we want to force the query planner to use the
+ # `ci_builds_gitlab_monitor_metrics` index all the time.
+ def pending_builds(timeout)
+ if Feature.enabled?(:ci_new_query_for_pending_stuck_jobs)
+ Ci::Build.pending.created_at_before(timeout).updated_at_before(timeout).order(created_at: :asc, project_id: :asc)
+ else
+ Ci::Build.pending.updated_before(lookback: BUILD_LOOKBACK.ago, timeout: timeout)
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def scheduled_timed_out_builds
+ Ci::Build.where(status: :scheduled).where( # rubocop: disable CodeReuse/ActiveRecord
+ 'ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?',
+ BUILD_SCHEDULED_OUTDATED_TIMEOUT.ago
+ )
+ end
+
+ def running_timed_out_builds
+ Ci::Build.running.where( # rubocop: disable CodeReuse/ActiveRecord
+ 'ci_builds.updated_at < ?',
+ BUILD_RUNNING_OUTDATED_TIMEOUT.ago
+ )
+ end
+ end
+ end
+end
diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb
index eea09e9ac67..c1cbf031ca1 100644
--- a/app/services/ci/update_build_queue_service.rb
+++ b/app/services/ci/update_build_queue_service.rb
@@ -99,15 +99,17 @@ module Ci
private
def tick_for(build, runners)
- runners = runners.with_recent_runner_queue
- runners = runners.with_tags if Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml)
+ ::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937') do
+ runners = runners.with_recent_runner_queue
+ runners = runners.with_tags if Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml)
- metrics.observe_active_runners(-> { runners.to_a.size })
+ metrics.observe_active_runners(-> { runners.to_a.size })
- runners.each do |runner|
- metrics.increment_runner_tick(runner)
+ runners.each do |runner|
+ metrics.increment_runner_tick(runner)
- runner.pick_build!(build)
+ runner.pick_build!(build)
+ end
end
end
diff --git a/app/services/ci/update_pending_build_service.rb b/app/services/ci/update_pending_build_service.rb
new file mode 100644
index 00000000000..dcba06e60bf
--- /dev/null
+++ b/app/services/ci/update_pending_build_service.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Ci
+ class UpdatePendingBuildService
+ VALID_PARAMS = %i[instance_runners_enabled].freeze
+
+ InvalidParamsError = Class.new(StandardError)
+ InvalidModelError = Class.new(StandardError)
+
+ def initialize(model, update_params)
+ @model = model
+ @update_params = update_params
+
+ validations!
+ end
+
+ def execute
+ return unless ::Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, @model, default_enabled: :yaml)
+
+ @model.pending_builds.each_batch do |relation|
+ relation.update_all(@update_params)
+ end
+ end
+
+ private
+
+ def validations!
+ validate_model! && validate_params!
+ end
+
+ def validate_model!
+ raise InvalidModelError unless @model.is_a?(::Project) || @model.is_a?(::Group)
+
+ true
+ end
+
+ def validate_params!
+ extra_params = @update_params.keys - VALID_PARAMS
+
+ raise InvalidParamsError, "Unvalid params: #{extra_params.join(', ')}" unless extra_params.empty?
+
+ true
+ end
+ end
+end
diff --git a/app/services/clusters/agents/refresh_authorization_service.rb b/app/services/clusters/agents/refresh_authorization_service.rb
new file mode 100644
index 00000000000..a9e3340dbf5
--- /dev/null
+++ b/app/services/clusters/agents/refresh_authorization_service.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class RefreshAuthorizationService
+ include Gitlab::Utils::StrongMemoize
+
+ AUTHORIZED_ENTITY_LIMIT = 100
+
+ delegate :project, to: :agent, private: true
+ delegate :root_ancestor, to: :project, private: true
+
+ def initialize(agent, config:)
+ @agent = agent
+ @config = config
+ end
+
+ def execute
+ refresh_projects!
+ refresh_groups!
+
+ true
+ end
+
+ private
+
+ attr_reader :agent, :config
+
+ def refresh_projects!
+ if allowed_project_configurations.present?
+ project_ids = allowed_project_configurations.map { |config| config.fetch(:project_id) }
+
+ agent.with_lock do
+ agent.project_authorizations.upsert_all(allowed_project_configurations, unique_by: [:agent_id, :project_id])
+ agent.project_authorizations.where.not(project_id: project_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord
+ end
+ else
+ agent.project_authorizations.delete_all(:delete_all)
+ end
+ end
+
+ def refresh_groups!
+ if allowed_group_configurations.present?
+ group_ids = allowed_group_configurations.map { |config| config.fetch(:group_id) }
+
+ agent.with_lock do
+ agent.group_authorizations.upsert_all(allowed_group_configurations, unique_by: [:agent_id, :group_id])
+ agent.group_authorizations.where.not(group_id: group_ids).delete_all # rubocop: disable CodeReuse/ActiveRecord
+ end
+ else
+ agent.group_authorizations.delete_all(:delete_all)
+ end
+ end
+
+ def allowed_project_configurations
+ strong_memoize(:allowed_project_configurations) do
+ project_entries = extract_config_entries(entity: 'projects')
+
+ if project_entries
+ allowed_projects.where_full_path_in(project_entries.keys).map do |project|
+ { project_id: project.id, config: project_entries[project.full_path] }
+ end
+ end
+ end
+ end
+
+ def allowed_group_configurations
+ strong_memoize(:allowed_group_configurations) do
+ group_entries = extract_config_entries(entity: 'groups')
+
+ if group_entries
+ allowed_groups.where_full_path_in(group_entries.keys).map do |group|
+ { group_id: group.id, config: group_entries[group.full_path] }
+ end
+ end
+ end
+ end
+
+ def extract_config_entries(entity:)
+ config.dig('ci_access', entity)
+ &.first(AUTHORIZED_ENTITY_LIMIT)
+ &.index_by { |config| config.delete('id') }
+ end
+
+ def allowed_projects
+ if group_root_ancestor?
+ root_ancestor.all_projects
+ else
+ ::Project.none
+ end
+ end
+
+ def allowed_groups
+ if group_root_ancestor?
+ root_ancestor.self_and_descendants
+ else
+ ::Group.none
+ end
+ end
+
+ def group_root_ancestor?
+ root_ancestor.group?
+ end
+ end
+ end
+end
diff --git a/app/services/concerns/members/bulk_create_users.rb b/app/services/concerns/members/bulk_create_users.rb
new file mode 100644
index 00000000000..4498f40c396
--- /dev/null
+++ b/app/services/concerns/members/bulk_create_users.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Members
+ module BulkCreateUsers
+ extend ActiveSupport::Concern
+
+ included do
+ class << self
+ def add_users(source, users, access_level, current_user: nil, expires_at: nil)
+ return [] unless users.present?
+
+ emails, users, existing_members = parse_users_list(source, users)
+
+ Member.transaction do
+ (emails + users).map! do |user|
+ new(source,
+ user,
+ access_level,
+ existing_members: existing_members,
+ current_user: current_user,
+ expires_at: expires_at)
+ .execute
+ end
+ end
+ end
+
+ private
+
+ def parse_users_list(source, list)
+ emails = []
+ user_ids = []
+ users = []
+ existing_members = {}
+
+ list.each do |item|
+ case item
+ when User
+ users << item
+ when Integer
+ user_ids << item
+ when /\A\d+\Z/
+ user_ids << item.to_i
+ when Devise.email_regexp
+ emails << item
+ end
+ end
+
+ if user_ids.present?
+ # the below will automatically discard invalid user_ids
+ users.concat(User.id_in(user_ids))
+ # helps not have to perform another query per user id to see if the member exists later on when fetching
+ existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ users.uniq! # de-duplicate just in case as there is no controlling if user records and ids are sent multiple times
+
+ [emails, users, existing_members]
+ end
+ end
+ end
+
+ def initialize(source, user, access_level, **args)
+ super
+
+ @existing_members = args[:existing_members] || (raise ArgumentError, "existing_members must be included in the args hash")
+ end
+
+ private
+
+ attr_reader :existing_members
+
+ def find_or_initialize_member_by_user
+ existing_members[user.id] || source.members.build(user_id: user.id)
+ end
+ end
+end
diff --git a/app/services/customer_relations/organizations/base_service.rb b/app/services/customer_relations/organizations/base_service.rb
new file mode 100644
index 00000000000..63261534b37
--- /dev/null
+++ b/app/services/customer_relations/organizations/base_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module CustomerRelations
+ module Organizations
+ class BaseService < ::BaseGroupService
+ private
+
+ def allowed?
+ current_user&.can?(:admin_organization, group)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+ end
+ end
+end
diff --git a/app/services/customer_relations/organizations/create_service.rb b/app/services/customer_relations/organizations/create_service.rb
new file mode 100644
index 00000000000..9c223796eaf
--- /dev/null
+++ b/app/services/customer_relations/organizations/create_service.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module CustomerRelations
+ module Organizations
+ class CreateService < BaseService
+ # returns the created organization
+ def execute
+ return error_no_permissions unless allowed?
+
+ params[:group_id] = group.id
+
+ organization = Organization.create(params)
+
+ return error_creating(organization) unless organization.persisted?
+
+ ServiceResponse.success(payload: organization)
+ end
+
+ private
+
+ def error_no_permissions
+ error('You have insufficient permissions to create an organization for this group')
+ end
+
+ def error_creating(organization)
+ error(organization&.errors&.full_messages || 'Failed to create organization')
+ end
+ end
+ end
+end
diff --git a/app/services/customer_relations/organizations/update_service.rb b/app/services/customer_relations/organizations/update_service.rb
new file mode 100644
index 00000000000..9d8f908db14
--- /dev/null
+++ b/app/services/customer_relations/organizations/update_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module CustomerRelations
+ module Organizations
+ class UpdateService < BaseService
+ def execute(organization)
+ return error_no_permissions unless allowed?
+ return error_updating(organization) unless organization.update(params)
+
+ ServiceResponse.success(payload: organization)
+ end
+
+ private
+
+ def error_no_permissions
+ error('You have insufficient permissions to update an organization for this group')
+ end
+
+ def error_updating(organization)
+ error(organization&.errors&.full_messages || 'Failed to update organization')
+ end
+ end
+ end
+end
diff --git a/app/services/dependency_proxy/image_ttl_group_policies/update_service.rb b/app/services/dependency_proxy/image_ttl_group_policies/update_service.rb
new file mode 100644
index 00000000000..3c0b6412dc5
--- /dev/null
+++ b/app/services/dependency_proxy/image_ttl_group_policies/update_service.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module DependencyProxy
+ module ImageTtlGroupPolicies
+ class UpdateService < BaseContainerService
+ include Gitlab::Utils::StrongMemoize
+
+ ALLOWED_ATTRIBUTES = %i[enabled ttl].freeze
+
+ def execute
+ return ServiceResponse.error(message: 'Access Denied', http_status: 403) unless allowed?
+ return ServiceResponse.error(message: 'Dependency proxy image TTL Policy not found', http_status: 404) unless dependency_proxy_image_ttl_policy
+
+ if dependency_proxy_image_ttl_policy.update(dependency_proxy_image_ttl_policy_params)
+ ServiceResponse.success(payload: { dependency_proxy_image_ttl_policy: dependency_proxy_image_ttl_policy })
+ else
+ ServiceResponse.error(
+ message: dependency_proxy_image_ttl_policy.errors.full_messages.to_sentence || 'Bad request',
+ http_status: 400
+ )
+ end
+ end
+
+ private
+
+ def dependency_proxy_image_ttl_policy
+ strong_memoize(:dependency_proxy_image_ttl_policy) do
+ container.dependency_proxy_image_ttl_policy
+ end
+ end
+
+ def allowed?
+ Ability.allowed?(current_user, :admin_dependency_proxy, container)
+ end
+
+ def dependency_proxy_image_ttl_policy_params
+ params.slice(*ALLOWED_ATTRIBUTES)
+ end
+ end
+ end
+end
diff --git a/app/services/design_management/delete_designs_service.rb b/app/services/design_management/delete_designs_service.rb
index 7f76bcc5626..9ed03a994c4 100644
--- a/app/services/design_management/delete_designs_service.rb
+++ b/app/services/design_management/delete_designs_service.rb
@@ -17,6 +17,7 @@ module DesignManagement
version = delete_designs!
EventCreateService.new.destroy_designs(designs, current_user)
Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_designs_removed_action(author: current_user)
+ TodosDestroyer::DestroyedDesignsWorker.perform_async(designs.map(&:id))
success(version: version)
end
diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb
index 3a1db16aaf4..a82a6e22a5a 100644
--- a/app/services/draft_notes/publish_service.rb
+++ b/app/services/draft_notes/publish_service.rb
@@ -34,22 +34,24 @@ module DraftNotes
created_notes = draft_notes.map do |draft_note|
draft_note.review = review
- create_note_from_draft(draft_note, skip_capture_diff_note_position: true)
+ create_note_from_draft(draft_note, skip_capture_diff_note_position: true, skip_keep_around_commits: true)
end
capture_diff_note_positions(created_notes)
+ keep_around_commits(created_notes)
draft_notes.delete_all
set_reviewed
notification_service.async.new_review(review)
MergeRequests::ResolvedDiscussionNotificationService.new(project: project, current_user: current_user).execute(merge_request)
end
- def create_note_from_draft(draft, skip_capture_diff_note_position: false)
+ def create_note_from_draft(draft, skip_capture_diff_note_position: false, skip_keep_around_commits: false)
# Make sure the diff file is unfolded in order to find the correct line
# codes.
draft.diff_file&.unfold_diff_lines(draft.original_position)
- note = Notes::CreateService.new(draft.project, draft.author, draft.publish_params).execute(
+ note_params = draft.publish_params.merge(skip_keep_around_commits: skip_keep_around_commits)
+ note = Notes::CreateService.new(draft.project, draft.author, note_params).execute(
skip_capture_diff_note_position: skip_capture_diff_note_position
)
@@ -86,5 +88,17 @@ module DraftNotes
capture_service.execute(note.discussion) if note.diff_note? && note.start_of_discussion?
end
end
+
+ def keep_around_commits(notes)
+ shas = notes.flat_map do |note|
+ note.shas if note.diff_note?
+ end.uniq
+
+ # We are allowing this since gitaly call will be created for each sha and
+ # even though they're unique, there will still be multiple Gitaly calls.
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ project.repository.keep_around(*shas)
+ end
+ end
end
end
diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb
index 288bee84ef7..d10833e66cb 100644
--- a/app/services/emails/destroy_service.rb
+++ b/app/services/emails/destroy_service.rb
@@ -3,14 +3,14 @@
module Emails
class DestroyService < ::Emails::BaseService
def execute(email)
- email.destroy && update_secondary_emails!
+ email.destroy && update_secondary_emails!(email.email)
end
private
- def update_secondary_emails!
+ def update_secondary_emails!(deleted_email)
result = ::Users::UpdateService.new(@current_user, user: @user).execute do |user|
- user.update_secondary_emails!
+ user.unset_secondary_emails_matching_deleted_email!(deleted_email)
end
result[:status] == :success
diff --git a/app/services/environments/auto_stop_service.rb b/app/services/environments/auto_stop_service.rb
index 4e3aec64283..686ba050326 100644
--- a/app/services/environments/auto_stop_service.rb
+++ b/app/services/environments/auto_stop_service.rb
@@ -28,11 +28,17 @@ module Environments
private
def stop_in_batch
- environments = Environment.auto_stoppable(BATCH_SIZE)
+ environments = Environment.preload_project.select(:id, :project_id).auto_stoppable(BATCH_SIZE)
- return false unless environments.exists?
+ return false if environments.empty?
- Environments::StopService.execute_in_batch(environments)
+ Environments::AutoStopWorker.bulk_perform_async_with_contexts(
+ environments,
+ arguments_proc: -> (environment) { environment.id },
+ context_proc: -> (environment) { { project: environment.project } }
+ )
+
+ true
end
end
end
diff --git a/app/services/environments/stop_service.rb b/app/services/environments/stop_service.rb
index 089aea11296..d9c66bd13fe 100644
--- a/app/services/environments/stop_service.rb
+++ b/app/services/environments/stop_service.rb
@@ -22,22 +22,6 @@ module Environments
merge_request.environments.each { |environment| execute(environment) }
end
- ##
- # This method is for stopping multiple environments in a batch style.
- # The maximum acceptable count of environments is roughly 5000. Please
- # apply acceptable `LIMIT` clause to the `environments` relation.
- def self.execute_in_batch(environments)
- stop_actions = environments.stop_actions.load
-
- environments.update_all(auto_stop_at: nil, state: 'stopped')
-
- stop_actions.each do |stop_action|
- stop_action.play(stop_action.user)
- rescue StandardError => e
- Gitlab::ErrorTracking.track_exception(e, deployable_id: stop_action.id)
- end
- end
-
private
def environments
diff --git a/app/services/error_tracking/collect_error_service.rb b/app/services/error_tracking/collect_error_service.rb
index bc1f238d81f..477453a693e 100644
--- a/app/services/error_tracking/collect_error_service.rb
+++ b/app/services/error_tracking/collect_error_service.rb
@@ -9,18 +9,18 @@ module ErrorTracking
error = project.error_tracking_errors.report_error(
name: exception['type'], # Example: ActionView::MissingTemplate
description: exception['value'], # Example: Missing template posts/show in...
- actor: event['transaction'], # Example: PostsController#show
+ actor: actor, # Example: PostsController#show
platform: event['platform'], # Example: ruby
- timestamp: event['timestamp']
+ timestamp: timestamp
)
# The payload field contains all the data on error including stacktrace in jsonb.
# Together with occured_at these are 2 main attributes that we need to save here.
error.events.create!(
environment: event['environment'],
- description: exception['type'],
+ description: exception['value'],
level: event['level'],
- occurred_at: event['timestamp'],
+ occurred_at: timestamp,
payload: event
)
end
@@ -34,5 +34,29 @@ module ErrorTracking
def exception
event['exception']['values'].first
end
+
+ def actor
+ return event['transaction'] if event['transaction']
+
+ # Some SDK do not have transaction attribute.
+ # So we build it by combining function name and module name from
+ # the last item in stacktrace.
+ last_line = exception.dig('stacktrace', 'frames').last
+
+ "#{last_line['function']}(#{last_line['module']})"
+ end
+
+ def timestamp
+ return @timestamp if @timestamp
+
+ @timestamp = (event['timestamp'] || Time.zone.now)
+
+ # Some SDK send timestamp in numeric format like '1630945472.13'.
+ if @timestamp.to_s =~ /\A\d+(\.\d+)?\z/
+ @timestamp = Time.zone.at(@timestamp.to_f)
+ end
+
+ @timestamp
+ end
end
end
diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb
index 5ddba748fd4..86c7791e759 100644
--- a/app/services/error_tracking/list_issues_service.rb
+++ b/app/services/error_tracking/list_issues_service.rb
@@ -74,7 +74,9 @@ module ErrorTracking
# We are going to support more options in the future.
# For now we implement the bare minimum for rendering the list in UI.
filter_opts = {
- status: opts[:issue_status]
+ status: opts[:issue_status],
+ sort: opts[:sort],
+ limit: opts[:limit]
}
errors = ErrorTracking::ErrorsFinder.new(current_user, project, filter_opts).execute
diff --git a/app/services/feature_flags/base_service.rb b/app/services/feature_flags/base_service.rb
index d041703803b..9ae9ab4de63 100644
--- a/app/services/feature_flags/base_service.rb
+++ b/app/services/feature_flags/base_service.rb
@@ -48,10 +48,11 @@ module FeatureFlags
end
end
- def created_scope_message(scope)
- "Created rule #{scope.environment_scope} "\
- "and set it as #{scope.active ? "active" : "inactive"} "\
- "with strategies #{scope.strategies}."
+ def created_strategy_message(strategy)
+ scopes = strategy.scopes
+ .map { |scope| %Q("#{scope.environment_scope}") }
+ .join(', ')
+ %Q(Created strategy "#{strategy.name}" with scopes #{scopes}.)
end
def feature_flag_by_name
diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb
index 5111f914447..65f8f8e33f6 100644
--- a/app/services/feature_flags/create_service.rb
+++ b/app/services/feature_flags/create_service.rb
@@ -24,8 +24,8 @@ module FeatureFlags
def audit_message(feature_flag)
message_parts = ["Created feature flag #{feature_flag.name} with description \"#{feature_flag.description}\"."]
- message_parts += feature_flag.scopes.map do |scope|
- created_scope_message(scope)
+ message_parts += feature_flag.strategies.map do |strategy|
+ created_strategy_message(strategy)
end
message_parts.join(" ")
diff --git a/app/services/feature_flags/update_service.rb b/app/services/feature_flags/update_service.rb
index 01e4f661d75..ccfd1b57d44 100644
--- a/app/services/feature_flags/update_service.rb
+++ b/app/services/feature_flags/update_service.rb
@@ -2,10 +2,9 @@
module FeatureFlags
class UpdateService < FeatureFlags::BaseService
- AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES = {
- 'active' => 'active state',
- 'environment_scope' => 'environment scope',
- 'strategies' => 'strategies'
+ AUDITABLE_STRATEGY_ATTRIBUTES_HUMAN_NAMES = {
+ 'scopes' => 'environment scopes',
+ 'parameters' => 'parameters'
}.freeze
def execute(feature_flag)
@@ -41,7 +40,7 @@ module FeatureFlags
def audit_message(feature_flag)
changes = changed_attributes_messages(feature_flag)
- changes += changed_scopes_messages(feature_flag)
+ changes += changed_strategies_messages(feature_flag)
return if changes.empty?
@@ -56,29 +55,30 @@ module FeatureFlags
end
end
- def changed_scopes_messages(feature_flag)
- feature_flag.scopes.map do |scope|
- if scope.new_record?
- created_scope_message(scope)
- elsif scope.marked_for_destruction?
- deleted_scope_message(scope)
+ def changed_strategies_messages(feature_flag)
+ feature_flag.strategies.map do |strategy|
+ if strategy.new_record?
+ created_strategy_message(strategy)
+ elsif strategy.marked_for_destruction?
+ deleted_strategy_message(strategy)
else
- updated_scope_message(scope)
+ updated_strategy_message(strategy)
end
- end.compact # updated_scope_message can return nil if nothing has been changed
+ end.compact # updated_strategy_message can return nil if nothing has been changed
end
- def deleted_scope_message(scope)
- "Deleted rule #{scope.environment_scope}."
+ def deleted_strategy_message(strategy)
+ scopes = strategy.scopes.map { |scope| scope.environment_scope }.join(', ')
+ "Deleted strategy #{strategy.name} with environment scopes #{scopes}."
end
- def updated_scope_message(scope)
- changes = scope.changes.slice(*AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES.keys)
+ def updated_strategy_message(strategy)
+ changes = strategy.changes.slice(*AUDITABLE_STRATEGY_ATTRIBUTES_HUMAN_NAMES.keys)
return if changes.empty?
- message = "Updated rule #{scope.environment_scope} "
+ message = "Updated strategy #{strategy.name} "
message += changes.map do |attribute_name, change|
- name = AUDITABLE_SCOPE_ATTRIBUTES_HUMAN_NAMES[attribute_name]
+ name = AUDITABLE_STRATEGY_ATTRIBUTES_HUMAN_NAMES[attribute_name]
"#{name} from #{change.first} to #{change.second}"
end.join(' ')
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index aee2f685e97..63f3f73905a 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -25,17 +25,13 @@ module Git
raise NotImplementedError, "Please implement #{self.class}##{__method__}"
end
- # The changeset, ordered with the newest commit last
- def commits
- raise NotImplementedError, "Please implement #{self.class}##{__method__}"
- end
-
+ # This should return PROCESS_COMMIT_LIMIT commits, ordered with newest last
def limited_commits
- @limited_commits ||= commits.last(PROCESS_COMMIT_LIMIT)
+ raise NotImplementedError, "Please implement #{self.class}##{__method__}"
end
def commits_count
- commits.count
+ raise NotImplementedError, "Please implement #{self.class}##{__method__}"
end
def event_message
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
index 7a22d7ffcdf..9b113be5465 100644
--- a/app/services/git/branch_hooks_service.rb
+++ b/app/services/git/branch_hooks_service.rb
@@ -18,20 +18,25 @@ module Git
:push_hooks
end
- def commits
- strong_memoize(:commits) do
+ def limited_commits
+ strong_memoize(:limited_commits) { threshold_commits.last(PROCESS_COMMIT_LIMIT) }
+ end
+
+ # Taking limit+1 commits allows us to detect when the limit is in effect
+ def threshold_commits
+ strong_memoize(:threshold_commits) do
if creating_default_branch?
# The most recent PROCESS_COMMIT_LIMIT commits in the default branch.
# They are returned newest-to-oldest, but we need to present them oldest-to-newest
- project.repository.commits(newrev, limit: PROCESS_COMMIT_LIMIT).reverse
+ project.repository.commits(newrev, limit: PROCESS_COMMIT_LIMIT + 1).reverse!
elsif creating_branch?
# Use the pushed commits that aren't reachable by the default branch
# as a heuristic. This may include more commits than are actually
# pushed, but that shouldn't matter because we check for existing
# cross-references later.
- project.repository.commits_between(project.default_branch, newrev)
+ project.repository.commits_between(project.default_branch, newrev, limit: PROCESS_COMMIT_LIMIT + 1)
elsif updating_branch?
- project.repository.commits_between(oldrev, newrev)
+ project.repository.commits_between(oldrev, newrev, limit: PROCESS_COMMIT_LIMIT + 1)
else # removing branch
[]
end
@@ -39,9 +44,21 @@ module Git
end
def commits_count
- return count_commits_in_branch if creating_default_branch?
+ strong_memoize(:commits_count) do
+ next threshold_commits.count if
+ strong_memoized?(:threshold_commits) &&
+ threshold_commits.count <= PROCESS_COMMIT_LIMIT
- super
+ if creating_default_branch?
+ project.repository.commit_count_for_ref(ref)
+ elsif creating_branch?
+ project.repository.count_commits_between(project.default_branch, newrev)
+ elsif updating_branch?
+ project.repository.count_commits_between(oldrev, newrev)
+ else # removing branch
+ 0
+ end
+ end
end
override :invalidated_file_types
@@ -179,12 +196,6 @@ module Git
creating_branch? && default_branch?
end
- def count_commits_in_branch
- strong_memoize(:count_commits_in_branch) do
- project.repository.commit_count_for_ref(ref)
- end
- end
-
def default_branch?
strong_memoize(:default_branch) do
[nil, branch_name].include?(project.default_branch)
diff --git a/app/services/git/tag_hooks_service.rb b/app/services/git/tag_hooks_service.rb
index d83924fec28..01174d8a942 100644
--- a/app/services/git/tag_hooks_service.rb
+++ b/app/services/git/tag_hooks_service.rb
@@ -8,10 +8,14 @@ module Git
:tag_push_hooks
end
- def commits
+ def limited_commits
[tag_commit].compact
end
+ def commits_count
+ limited_commits.count
+ end
+
def event_message
tag&.message
end
diff --git a/app/services/groups/open_issues_count_service.rb b/app/services/groups/open_issues_count_service.rb
index 17cf3d38987..c18d239998b 100644
--- a/app/services/groups/open_issues_count_service.rb
+++ b/app/services/groups/open_issues_count_service.rb
@@ -3,11 +3,15 @@
module Groups
# Service class for counting and caching the number of open issues of a group.
class OpenIssuesCountService < Groups::CountService
- PUBLIC_COUNT_KEY = 'group_public_open_issues_count'
- TOTAL_COUNT_KEY = 'group_total_open_issues_count'
+ # TOTAL_COUNT_KEY includes confidential and hidden issues (admin)
+ # TOTAL_COUNT_WITHOUT_HIDDEN_KEY includes confidential issues but not hidden issues (reporter and above)
+ # PUBLIC_COUNT_WITHOUT_HIDDEN_KEY does not include confidential or hidden issues (guest)
+ TOTAL_COUNT_KEY = 'group_open_issues_including_hidden_count'
+ TOTAL_COUNT_WITHOUT_HIDDEN_KEY = 'group_open_issues_without_hidden_count'
+ PUBLIC_COUNT_WITHOUT_HIDDEN_KEY = 'group_open_public_issues_without_hidden_count'
def clear_all_cache_keys
- [cache_key(PUBLIC_COUNT_KEY), cache_key(TOTAL_COUNT_KEY)].each do |key|
+ [cache_key(TOTAL_COUNT_KEY), cache_key(TOTAL_COUNT_WITHOUT_HIDDEN_KEY), cache_key(PUBLIC_COUNT_WITHOUT_HIDDEN_KEY)].each do |key|
Rails.cache.delete(key)
end
end
@@ -15,7 +19,19 @@ module Groups
private
def cache_key_name
- public_only? ? PUBLIC_COUNT_KEY : TOTAL_COUNT_KEY
+ if include_hidden?
+ TOTAL_COUNT_KEY
+ elsif public_only?
+ PUBLIC_COUNT_WITHOUT_HIDDEN_KEY
+ else
+ TOTAL_COUNT_WITHOUT_HIDDEN_KEY
+ end
+ end
+
+ def include_hidden?
+ strong_memoize(:user_is_admin) do
+ user&.can_admin_all_resources?
+ end
end
def public_only?
@@ -35,7 +51,8 @@ module Groups
state: 'opened',
non_archived: true,
include_subgroups: true,
- public_only: public_only?
+ public_only: public_only?,
+ include_hidden: include_hidden?
).execute
end
diff --git a/app/services/groups/update_shared_runners_service.rb b/app/services/groups/update_shared_runners_service.rb
index 639c5bf6ae0..eb6b46a5613 100644
--- a/app/services/groups/update_shared_runners_service.rb
+++ b/app/services/groups/update_shared_runners_service.rb
@@ -8,6 +8,7 @@ module Groups
validate_params
update_shared_runners
+ update_pending_builds!
success
@@ -26,5 +27,13 @@ module Groups
def update_shared_runners
group.update_shared_runners_setting!(params[:shared_runners_setting])
end
+
+ def update_pending_builds!
+ return unless group.previous_changes.include?('shared_runners_enabled')
+
+ update_params = { instance_runners_enabled: group.shared_runners_enabled }
+
+ ::Ci::UpdatePendingBuildService.new(group, update_params).execute
+ end
end
end
diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb
index cd32cd78728..238f5ebddae 100644
--- a/app/services/issuable/bulk_update_service.rb
+++ b/app/services/issuable/bulk_update_service.rb
@@ -57,7 +57,11 @@ module Issuable
items.each do |issuable|
next unless can?(current_user, :"update_#{type}", issuable)
- update_class.new(**update_class.constructor_container_arg(issuable.issuing_parent), current_user: current_user, params: params).execute(issuable)
+ update_class.new(
+ **update_class.constructor_container_arg(issuable.issuing_parent),
+ current_user: current_user,
+ params: dup_params
+ ).execute(issuable)
end
items
@@ -78,6 +82,19 @@ module Issuable
.includes_for_bulk_update
end
+ # Duplicates params and its top-level values
+ # We cannot use deep_dup because ActiveRecord objects will result
+ # to new records with no id assigned
+ def dup_params
+ dup = HashWithIndifferentAccess.new
+
+ params.each do |key, value|
+ dup[key] = value.is_a?(ActiveRecord::Base) ? value : value.dup
+ end
+
+ dup
+ end
+
def response_success(message: nil, payload: nil)
ServiceResponse.success(message: message, payload: payload)
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 0984238517e..59e521853de 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -51,9 +51,12 @@ class IssuableBaseService < ::BaseProjectService
params.delete(:canonical_issue_id)
params.delete(:project)
params.delete(:discussion_locked)
- params.delete(:confidential)
end
+ # confidential attribute is a special type of metadata and needs to be allowed to be set
+ # by non-members on issues in public projects so that security issues can be reported as confidential.
+ params.delete(:confidential) unless can?(current_user, :set_confidentiality, issuable)
+
filter_assignees(issuable)
filter_milestone
filter_labels
diff --git a/app/services/issue_rebalancing_service.rb b/app/services/issue_rebalancing_service.rb
deleted file mode 100644
index 142d287370f..00000000000
--- a/app/services/issue_rebalancing_service.rb
+++ /dev/null
@@ -1,136 +0,0 @@
-# frozen_string_literal: true
-
-class IssueRebalancingService
- MAX_ISSUE_COUNT = 10_000
- BATCH_SIZE = 100
- SMALLEST_BATCH_SIZE = 5
- RETRIES_LIMIT = 3
- TooManyIssues = Class.new(StandardError)
-
- TIMING_CONFIGURATION = [
- [0.1.seconds, 0.05.seconds], # short timings, lock_timeout: 100ms, sleep after LockWaitTimeout: 50ms
- [0.5.seconds, 0.05.seconds],
- [1.second, 0.5.seconds],
- [1.second, 0.5.seconds],
- [5.seconds, 1.second]
- ].freeze
-
- def initialize(projects_collection)
- @root_namespace = projects_collection.take.root_namespace # rubocop:disable CodeReuse/ActiveRecord
- @base = Issue.in_projects(projects_collection)
- end
-
- def execute
- return unless Feature.enabled?(:rebalance_issues, root_namespace)
-
- raise TooManyIssues, "#{issue_count} issues" if issue_count > MAX_ISSUE_COUNT
-
- start = RelativePositioning::START_POSITION - (gaps / 2) * gap_size
-
- if Feature.enabled?(:issue_rebalancing_optimization)
- Issue.transaction do
- assign_positions(start, indexed_ids)
- .sort_by(&:first)
- .each_slice(BATCH_SIZE) do |pairs_with_position|
- if Feature.enabled?(:issue_rebalancing_with_retry)
- update_positions_with_retry(pairs_with_position, 'rebalance issue positions in batches ordered by id')
- else
- update_positions(pairs_with_position, 'rebalance issue positions in batches ordered by id')
- end
- end
- end
- else
- Issue.transaction do
- indexed_ids.each_slice(BATCH_SIZE) do |pairs|
- pairs_with_position = assign_positions(start, pairs)
-
- if Feature.enabled?(:issue_rebalancing_with_retry)
- update_positions_with_retry(pairs_with_position, 'rebalance issue positions')
- else
- update_positions(pairs_with_position, 'rebalance issue positions')
- end
- end
- end
- end
- end
-
- private
-
- attr_reader :root_namespace, :base
-
- # rubocop: disable CodeReuse/ActiveRecord
- def indexed_ids
- base.reorder(:relative_position, :id).pluck(:id).each_with_index
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def assign_positions(start, pairs)
- pairs.map do |id, index|
- [id, start + (index * gap_size)]
- end
- end
-
- def update_positions_with_retry(pairs_with_position, query_name)
- retries = 0
- batch_size = pairs_with_position.size
-
- until pairs_with_position.empty?
- begin
- update_positions(pairs_with_position.first(batch_size), query_name)
- pairs_with_position = pairs_with_position.drop(batch_size)
- retries = 0
- rescue ActiveRecord::StatementTimeout, ActiveRecord::QueryCanceled => ex
- raise ex if batch_size < SMALLEST_BATCH_SIZE
-
- if (retries += 1) == RETRIES_LIMIT
- # shrink the batch size in half when RETRIES limit is reached and update still fails perhaps because batch size is still too big
- batch_size = (batch_size / 2).to_i
- retries = 0
- end
-
- retry
- end
- end
- end
-
- def update_positions(pairs_with_position, query_name)
- values = pairs_with_position.map do |id, index|
- "(#{id}, #{index})"
- end.join(', ')
-
- Gitlab::Database::WithLockRetries.new(timing_configuration: TIMING_CONFIGURATION, klass: self.class).run do
- run_update_query(values, query_name)
- end
- end
-
- def run_update_query(values, query_name)
- Issue.connection.exec_query(<<~SQL, query_name)
- WITH cte(cte_id, new_pos) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
- SELECT *
- FROM (VALUES #{values}) as t (id, pos)
- )
- UPDATE #{Issue.table_name}
- SET relative_position = cte.new_pos
- FROM cte
- WHERE cte_id = id
- SQL
- end
-
- def issue_count
- @issue_count ||= base.count
- end
-
- def gaps
- issue_count - 1
- end
-
- def gap_size
- # We could try to split the available range over the number of gaps we need,
- # but IDEAL_DISTANCE * MAX_ISSUE_COUNT is only 0.1% of the available range,
- # so we are guaranteed not to exhaust it by using this static value.
- #
- # If we raise MAX_ISSUE_COUNT or IDEAL_DISTANCE significantly, this may
- # change!
- RelativePositioning::IDEAL_DISTANCE
- end
-end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index 5e0a86fdeee..6dce9fd6e73 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -34,6 +34,13 @@ module Issues
private
+ def find_work_item_type_id(issue_type)
+ work_item_type = WorkItem::Type.default_by_type(issue_type)
+ work_item_type ||= WorkItem::Type.default_issue_type
+
+ work_item_type.id
+ end
+
def filter_params(issue)
super
@@ -84,7 +91,8 @@ module Issues
# @param object [Issue, Project]
def issue_type_allowed?(object)
- can?(current_user, :"create_#{params[:issue_type]}", object)
+ WorkItem::Type.base_types.key?(params[:issue_type]) &&
+ can?(current_user, :"create_#{params[:issue_type]}", object)
end
# @param issue [Issue]
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index 5cb138946d7..7fdc8daf15c 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -6,6 +6,7 @@ module Issues
def execute
filter_resolve_discussion_params
+
@issue = project.issues.new(issue_params).tap do |issue|
ensure_milestone_available(issue)
end
@@ -60,6 +61,13 @@ module Issues
def issue_params
@issue_params ||= build_issue_params
+
+ # If :issue_type is nil then params[:issue_type] was either nil
+ # or not permitted. Either way, the :issue_type will default
+ # to the column default of `issue`. And that means we need to
+ # ensure the work_item_type_id is set
+ @issue_params[:work_item_type_id] = get_work_item_type_id(@issue_params[:issue_type])
+ @issue_params
end
private
@@ -81,6 +89,11 @@ module Issues
{ author: current_user }
.merge(issue_params_with_info_from_discussions)
.merge(allowed_issue_params)
+ .with_indifferent_access
+ end
+
+ def get_work_item_type_id(issue_type = :issue)
+ find_work_item_type_id(issue_type)
end
end
end
diff --git a/app/services/issues/relative_position_rebalancing_service.rb b/app/services/issues/relative_position_rebalancing_service.rb
new file mode 100644
index 00000000000..7d199f99a24
--- /dev/null
+++ b/app/services/issues/relative_position_rebalancing_service.rb
@@ -0,0 +1,193 @@
+# frozen_string_literal: true
+
+module Issues
+ class RelativePositionRebalancingService
+ UPDATE_BATCH_SIZE = 100
+ PREFETCH_ISSUES_BATCH_SIZE = 10_000
+ SMALLEST_BATCH_SIZE = 5
+ RETRIES_LIMIT = 3
+
+ TooManyConcurrentRebalances = Class.new(StandardError)
+
+ def initialize(projects)
+ @projects_collection = (projects.is_a?(Array) ? Project.id_in(projects) : projects).projects_order_id_asc
+ @root_namespace = @projects_collection.take.root_namespace # rubocop:disable CodeReuse/ActiveRecord
+ @caching = ::Gitlab::Issues::Rebalancing::State.new(@root_namespace, @projects_collection)
+ end
+
+ def execute
+ return unless Feature.enabled?(:rebalance_issues, root_namespace)
+
+ # Given can_start_rebalance? and track_new_running_rebalance are not atomic
+ # it can happen that we end up with more than Rebalancing::State::MAX_NUMBER_OF_CONCURRENT_REBALANCES running.
+ # Considering the number of allowed Rebalancing::State::MAX_NUMBER_OF_CONCURRENT_REBALANCES is small we should be ok,
+ # but should be something to consider if we'd want to scale this up.
+ error_message = "#{caching.concurrent_running_rebalances_count} concurrent re-balances currently running"
+ raise TooManyConcurrentRebalances, error_message unless caching.can_start_rebalance?
+
+ block_issue_repositioning! unless root_namespace.issue_repositioning_disabled?
+ caching.track_new_running_rebalance
+ index = caching.get_current_index
+
+ loop do
+ issue_ids = get_issue_ids(index, PREFETCH_ISSUES_BATCH_SIZE)
+ pairs_with_index = assign_indexes(issue_ids, index)
+
+ pairs_with_index.each_slice(UPDATE_BATCH_SIZE) do |pairs_batch|
+ update_positions_with_retry(pairs_batch, 're-balance issue positions in batches ordered by position')
+ end
+
+ index = caching.get_current_index
+
+ break if index >= caching.issue_count - 1
+ end
+
+ caching.cleanup_cache
+ unblock_issue_repositioning!
+ end
+
+ private
+
+ attr_reader :root_namespace, :projects_collection, :caching
+
+ def block_issue_repositioning!
+ Feature.enable(:block_issue_repositioning, root_namespace)
+ end
+
+ def unblock_issue_repositioning!
+ Feature.disable(:block_issue_repositioning, root_namespace)
+ end
+
+ def get_issue_ids(index, limit)
+ issue_ids = caching.get_cached_issue_ids(index, limit)
+
+ # if we have a list of cached issues and no current project id cached,
+ # then we successfully cached issues for all projects
+ return issue_ids if issue_ids.any? && caching.get_current_project_id.blank?
+
+ # if we got no issue ids at the start of re-balancing then we did not cache any issue ids yet
+ preload_issue_ids
+
+ caching.get_cached_issue_ids(index, limit)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def preload_issue_ids
+ index = 0
+ cached_project_id = caching.get_current_project_id
+
+ collection = projects_collection
+ collection = projects_collection.where(Project.arel_table[:id].gteq(cached_project_id.to_i)) if cached_project_id.present?
+
+ collection.each do |project|
+ caching.cache_current_project_id(project.id)
+ index += 1
+ scope = Issue.in_projects(project).reorder(custom_reorder).select(:id, :relative_position)
+
+ with_retry(PREFETCH_ISSUES_BATCH_SIZE, 100) do |batch_size|
+ Gitlab::Pagination::Keyset::Iterator.new(scope: scope).each_batch(of: batch_size) do |batch|
+ caching.cache_issue_ids(batch)
+ end
+ end
+ end
+
+ caching.remove_current_project_id_cache
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def assign_indexes(ids, start_index)
+ ids.each_with_index.map do |id, idx|
+ [id, start_index + idx]
+ end
+ end
+
+ # The method runs in a loop where we try for RETRIES_LIMIT=3 times, to run the update statement on
+ # a number of records(batch size). Method gets an array of (id, value) pairs as argument that is used
+ # to build the update query matching by id and updating relative_position = value. If we get a statement
+ # timeout, we split the batch size in half and try(for 3 times again) to batch update on a smaller number of records.
+ # On success, because we know the batch size and we always pick from the beginning of the array param,
+ # we can remove first batch_size number of items from array and continue with the successful batch_size for next batches.
+ # On failures we continue to split batch size to a SMALLEST_BATCH_SIZE limit, which is now set at 5.
+ #
+ # e.g.
+ # 0. items | previous batch size|new batch size | comment
+ # 1. 100 | 100 | 100 | 3 failures -> split the batch size in half
+ # 2. 100 | 100 | 50 | 3 failures -> split the batch size in half again
+ # 3. 100 | 50 | 25 | 3 succeed -> so we drop 25 items 3 times, 4th fails -> split the batch size in half again
+ # 5. 25 | 25 | 12 | 3 failures -> split the batch size in half
+ # 6. 25 | 12 | 6 | 3 failures -> we exit because smallest batch size is 5 and we'll be at 3 if we split again
+
+ def update_positions_with_retry(pairs_with_index, query_name)
+ retry_batch_size = pairs_with_index.size
+
+ until pairs_with_index.empty?
+ with_retry(retry_batch_size, SMALLEST_BATCH_SIZE) do |batch_size|
+ retry_batch_size = batch_size
+ update_positions(pairs_with_index.first(batch_size), query_name)
+ # pairs_with_index[batch_size - 1] - can be nil for last batch
+ # if last batch is smaller than batch_size, so we just get the last pair.
+ last_pair_in_batch = pairs_with_index[batch_size - 1] || pairs_with_index.last
+ caching.cache_current_index(last_pair_in_batch.last + 1)
+ pairs_with_index = pairs_with_index.drop(batch_size)
+ end
+ end
+ end
+
+ def update_positions(pairs_with_position, query_name)
+ values = pairs_with_position.map do |id, index|
+ "(#{id}, #{start_position + (index * gap_size)})"
+ end.join(', ')
+
+ run_update_query(values, query_name)
+ end
+
+ def run_update_query(values, query_name)
+ Issue.connection.exec_query(<<~SQL, query_name)
+ WITH cte(cte_id, new_pos) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (
+ SELECT *
+ FROM (VALUES #{values}) as t (id, pos)
+ )
+ UPDATE #{Issue.table_name}
+ SET relative_position = cte.new_pos
+ FROM cte
+ WHERE cte_id = id
+ SQL
+ end
+
+ def gaps
+ caching.issue_count - 1
+ end
+
+ def gap_size
+ RelativePositioning::MAX_GAP
+ end
+
+ def start_position
+ @start_position ||= (RelativePositioning::START_POSITION - (gaps / 2) * gap_size).to_i
+ end
+
+ def custom_reorder
+ ::Gitlab::Pagination::Keyset::Order.build([Issue.column_order_relative_position, Issue.column_order_id_asc])
+ end
+
+ def with_retry(initial_batch_size, exit_batch_size)
+ retries = 0
+ batch_size = initial_batch_size
+
+ begin
+ yield batch_size
+ retries = 0
+ rescue ActiveRecord::StatementTimeout, ActiveRecord::QueryCanceled => ex
+ raise ex if batch_size < exit_batch_size
+
+ if (retries += 1) == RETRIES_LIMIT
+ # shrink the batch size in half when RETRIES limit is reached and update still fails perhaps because batch size is still too big
+ batch_size = (batch_size / 2).to_i
+ retries = 0
+ end
+
+ retry
+ end
+ end
+ end
+end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index 9ede5ef728b..d120b007af2 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -26,6 +26,8 @@ module Issues
end
def before_update(issue, skip_spam_check: false)
+ change_work_item_type(issue)
+
return if skip_spam_check
Spam::SpamActionService.new(
@@ -36,6 +38,14 @@ module Issues
).execute
end
+ def change_work_item_type(issue)
+ return unless issue.changed_attributes['issue_type']
+
+ type_id = find_work_item_type_id(issue.issue_type)
+
+ issue.work_item_type_id = type_id
+ end
+
def handle_changes(issue, options)
super
old_associations = options.fetch(:old_associations, {})
@@ -54,29 +64,12 @@ module Issues
end
handle_assignee_changes(issue, old_assignees)
-
- if issue.previous_changes.include?('confidential')
- # don't enqueue immediately to prevent todos removal in case of a mistake
- TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, issue.id) if issue.confidential?
- create_confidentiality_note(issue)
- track_usage_event(:incident_management_incident_change_confidential, current_user.id)
- end
-
- added_labels = issue.labels - old_labels
-
- if added_labels.present?
- notification_service.async.relabeled_issue(issue, added_labels, current_user)
- end
-
+ handle_confidential_change(issue)
+ handle_added_labels(issue, old_labels)
handle_milestone_change(issue)
-
- added_mentions = issue.mentioned_users(current_user) - old_mentioned_users
-
- if added_mentions.present?
- notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user)
- end
-
+ handle_added_mentions(issue, old_mentioned_users)
handle_severity_change(issue, old_severity)
+ handle_issue_type_change(issue)
end
def handle_assignee_changes(issue, old_assignees)
@@ -156,6 +149,23 @@ module Issues
MergeRequests::CreateFromIssueService.new(project: project, current_user: current_user, mr_params: create_merge_request_params).execute
end
+ def handle_confidential_change(issue)
+ if issue.previous_changes.include?('confidential')
+ # don't enqueue immediately to prevent todos removal in case of a mistake
+ TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, issue.id) if issue.confidential?
+ create_confidentiality_note(issue)
+ track_usage_event(:incident_management_incident_change_confidential, current_user.id)
+ end
+ end
+
+ def handle_added_labels(issue, old_labels)
+ added_labels = issue.labels - old_labels
+
+ if added_labels.present?
+ notification_service.async.relabeled_issue(issue, added_labels, current_user)
+ end
+ end
+
def handle_milestone_change(issue)
return unless issue.previous_changes.include?('milestone_id')
@@ -184,6 +194,14 @@ module Issues
end
end
+ def handle_added_mentions(issue, old_mentioned_users)
+ added_mentions = issue.mentioned_users(current_user) - old_mentioned_users
+
+ if added_mentions.present?
+ notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user)
+ end
+ end
+
def handle_severity_change(issue, old_severity)
return unless old_severity && issue.severity != old_severity
@@ -218,6 +236,16 @@ module Issues
def remove_incident_label?(issue)
issue.issue_type != params[:issue_type] && issue.incident?
end
+
+ def handle_issue_type_change(issue)
+ return unless issue.previous_changes.include?('issue_type')
+
+ do_handle_issue_type_change(issue)
+ end
+
+ def do_handle_issue_type_change(issue)
+ SystemNoteService.change_issue_type(issue, current_user)
+ end
end
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 3e809b11024..0cc62e661a3 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -4,9 +4,12 @@ module Members
class CreateService < Members::BaseService
BlankInvitesError = Class.new(StandardError)
TooManyInvitesError = Class.new(StandardError)
+ MembershipLockedError = Class.new(StandardError)
DEFAULT_INVITE_LIMIT = 100
+ attr_reader :membership_locked
+
def initialize(*args)
super
@@ -17,18 +20,22 @@ module Members
def execute
validate_invite_source!
- validate_invites!
+ validate_invitable!
add_members
enqueue_onboarding_progress_action
result
- rescue BlankInvitesError, TooManyInvitesError => e
+ rescue BlankInvitesError, TooManyInvitesError, MembershipLockedError => e
error(e.message)
end
+ def single_member
+ members.last
+ end
+
private
- attr_reader :source, :errors, :invites, :member_created_namespace_id
+ attr_reader :source, :errors, :invites, :member_created_namespace_id, :members
def invites_from_params
params[:user_ids]
@@ -38,7 +45,7 @@ module Members
raise ArgumentError, s_('AddMember|No invite source provided.') unless invite_source.present?
end
- def validate_invites!
+ def validate_invitable!
raise BlankInvitesError, blank_invites_message if invites.blank?
return unless user_limit && invites.size > user_limit
@@ -52,7 +59,7 @@ module Members
end
def add_members
- members = source.add_users(
+ @members = source.add_users(
invites,
params[:access_level],
expires_at: params[:expires_at],
diff --git a/app/services/members/creator_service.rb b/app/services/members/creator_service.rb
index f6972f81162..7b0bebff760 100644
--- a/app/services/members/creator_service.rb
+++ b/app/services/members/creator_service.rb
@@ -12,54 +12,6 @@ module Members
def access_levels
raise NotImplementedError
end
-
- def add_users(source, users, access_level, current_user: nil, expires_at: nil)
- return [] unless users.present?
-
- emails, users, existing_members = parse_users_list(source, users)
-
- Member.transaction do
- (emails + users).map! do |user|
- new(source,
- user,
- access_level,
- existing_members: existing_members,
- current_user: current_user,
- expires_at: expires_at)
- .execute
- end
- end
- end
-
- private
-
- def parse_users_list(source, list)
- emails = []
- user_ids = []
- users = []
- existing_members = {}
-
- list.each do |item|
- case item
- when User
- users << item
- when Integer
- user_ids << item
- when /\A\d+\Z/
- user_ids << item.to_i
- when Devise.email_regexp
- emails << item
- end
- end
-
- if user_ids.present?
- users.concat(User.id_in(user_ids))
- # the below will automatically discard invalid user_ids
- existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id) # rubocop:todo CodeReuse/ActiveRecord
- end
-
- [emails, users, existing_members]
- end
end
def initialize(source, user, access_level, **args)
@@ -149,18 +101,10 @@ module Members
end
def find_or_initialize_member_by_user
- if existing_members
- # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/334062
- # i'm not so sure this is needed as the parse_users_list looks at members_and_requesters...
- # so it is like we could just do a find or initialize by here and be fine
- existing_members[user.id] || source.members.build(user_id: user.id)
- else
- source.members_and_requesters.find_or_initialize_by(user_id: user.id) # rubocop:todo CodeReuse/ActiveRecord
- end
- end
-
- def existing_members
- args[:existing_members]
+ # have to use members and requesters here since project/group limits on requested_at being nil for members and
+ # wouldn't be found in `source.members` if it already existed
+ # this of course will not treat active invites the same since we aren't searching on email
+ source.members_and_requesters.find_or_initialize_by(user_id: user.id) # rubocop:disable CodeReuse/ActiveRecord
end
def ldap
diff --git a/app/services/members/groups/bulk_creator_service.rb b/app/services/members/groups/bulk_creator_service.rb
new file mode 100644
index 00000000000..57cec241584
--- /dev/null
+++ b/app/services/members/groups/bulk_creator_service.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Members
+ module Groups
+ class BulkCreatorService < Members::Groups::CreatorService
+ include Members::BulkCreateUsers
+ end
+ end
+end
diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb
index 6298943977b..257a986b8dd 100644
--- a/app/services/members/invite_service.rb
+++ b/app/services/members/invite_service.rb
@@ -18,7 +18,7 @@ module Members
params[:email]
end
- def validate_invites!
+ def validate_invitable!
super
# we need the below due to add_users hitting Members::CreatorService.parse_users_list and ignoring invalid emails
diff --git a/app/services/members/mailgun.rb b/app/services/members/mailgun.rb
new file mode 100644
index 00000000000..43fb5a14ef1
--- /dev/null
+++ b/app/services/members/mailgun.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Members
+ module Mailgun
+ INVITE_EMAIL_TAG = 'invite_email'
+ INVITE_EMAIL_TOKEN_KEY = :invite_token
+ end
+end
diff --git a/app/services/members/mailgun/process_webhook_service.rb b/app/services/members/mailgun/process_webhook_service.rb
new file mode 100644
index 00000000000..e359a83ad42
--- /dev/null
+++ b/app/services/members/mailgun/process_webhook_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Members
+ module Mailgun
+ class ProcessWebhookService
+ ProcessWebhookServiceError = Class.new(StandardError)
+
+ def initialize(payload)
+ @payload = payload
+ end
+
+ def execute
+ @member = Member.find_by_invite_token(invite_token)
+ update_member_and_log if member
+ rescue ProcessWebhookServiceError => e
+ Gitlab::ErrorTracking.track_exception(e)
+ end
+
+ private
+
+ attr_reader :payload, :member
+
+ def update_member_and_log
+ log_update_event if member.update(invite_email_success: false)
+ end
+
+ def log_update_event
+ Gitlab::AppLogger.info "UPDATED MEMBER INVITE_EMAIL_SUCCESS: member_id: #{member.id}"
+ end
+
+ def invite_token
+ # may want to validate schema in some way using ::JSONSchemer.schema(SCHEMA_PATH).valid?(message) if this
+ # gets more complex
+ payload.dig('user-variables', ::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY) ||
+ raise(ProcessWebhookServiceError, "Failed to receive #{::Members::Mailgun::INVITE_EMAIL_TOKEN_KEY} in user-variables: #{payload}")
+ end
+ end
+ end
+end
diff --git a/app/services/members/projects/bulk_creator_service.rb b/app/services/members/projects/bulk_creator_service.rb
new file mode 100644
index 00000000000..68e71e35d12
--- /dev/null
+++ b/app/services/members/projects/bulk_creator_service.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Members
+ module Projects
+ class BulkCreatorService < Members::Projects::CreatorService
+ include Members::BulkCreateUsers
+ end
+ end
+end
diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb
index eda652c4b9a..8519cbac3cb 100644
--- a/app/services/merge_requests/merge_to_ref_service.rb
+++ b/app/services/merge_requests/merge_to_ref_service.rb
@@ -13,12 +13,12 @@ module MergeRequests
class MergeToRefService < MergeRequests::MergeBaseService
extend ::Gitlab::Utils::Override
- def execute(merge_request)
+ def execute(merge_request, cache_merge_to_ref_calls = false)
@merge_request = merge_request
error_check!
- commit_id = commit
+ commit_id = commit(cache_merge_to_ref_calls)
raise_error('Conflicts detected during merge') unless commit_id
@@ -65,8 +65,8 @@ module MergeRequests
params[:allow_conflicts] || false
end
- def commit
- if Feature.enabled?(:cache_merge_to_ref_calls, project, default_enabled: false)
+ def commit(cache_merge_to_ref_calls = false)
+ if cache_merge_to_ref_calls
Rails.cache.fetch(cache_key, expires_in: 1.day) do
extracted_merge_to_ref
end
diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb
index 3e294aeaa07..c3498c5ce97 100644
--- a/app/services/merge_requests/mergeability_check_service.rb
+++ b/app/services/merge_requests/mergeability_check_service.rb
@@ -157,7 +157,9 @@ module MergeRequests
def merge_to_ref
params = { allow_conflicts: Feature.enabled?(:display_merge_conflicts_in_diff, project) }
- result = MergeRequests::MergeToRefService.new(project: project, current_user: merge_request.author, params: params).execute(merge_request)
+ result = MergeRequests::MergeToRefService
+ .new(project: project, current_user: merge_request.author, params: params)
+ .execute(merge_request, true)
result[:status] == :success
end
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index 31c49b3ae70..102f78c6a9b 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -2,8 +2,6 @@
module MergeRequests
class SquashService < MergeRequests::BaseService
- SquashInProgressError = Class.new(RuntimeError)
-
def execute
# If performing a squash would result in no change, then
# immediately return a success message without performing a squash
@@ -13,14 +11,7 @@ module MergeRequests
return error(s_('MergeRequests|This project does not allow squashing commits when merge requests are accepted.')) if squash_forbidden?
- if squash_in_progress?
- return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.'))
- end
-
squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.'))
-
- rescue SquashInProgressError
- error(s_('MergeRequests|An error occurred while checking whether another squash is in progress.'))
end
private
@@ -35,14 +26,6 @@ module MergeRequests
false
end
- def squash_in_progress?
- merge_request.squash_in_progress?
- rescue StandardError => e
- log_error(exception: e, message: 'Failed to check squash in progress')
-
- raise SquashInProgressError, e.message
- end
-
def squash_forbidden?
target_project.squash_never?
end
diff --git a/app/services/packages/composer/version_parser_service.rb b/app/services/packages/composer/version_parser_service.rb
index 36275d1b680..516c306d2a5 100644
--- a/app/services/packages/composer/version_parser_service.rb
+++ b/app/services/packages/composer/version_parser_service.rb
@@ -12,18 +12,19 @@ module Packages
if @tag_name.present?
@tag_name.delete_prefix('v')
elsif @branch_name.present?
- branch_sufix_or_prefix(@branch_name.match(Gitlab::Regex.composer_package_version_regex))
+ branch_suffix_or_prefix(@branch_name.match(Gitlab::Regex.composer_package_version_regex))
end
end
private
- def branch_sufix_or_prefix(match)
+ def branch_suffix_or_prefix(match)
if match
- if match.captures[1] == '.x'
- match.captures[0] + '-dev'
+ captures = match.captures.reject(&:blank?)
+ if captures[-1] == '.x'
+ captures[0] + '-dev'
else
- match.captures[0] + '.x-dev'
+ captures[0] + '.x-dev'
end
else
"dev-#{@branch_name}"
diff --git a/app/services/packages/create_package_service.rb b/app/services/packages/create_package_service.rb
index 3dc06497d9f..7e1b6ecbe51 100644
--- a/app/services/packages/create_package_service.rb
+++ b/app/services/packages/create_package_service.rb
@@ -5,15 +5,19 @@ module Packages
protected
def find_or_create_package!(package_type, name: params[:name], version: params[:version])
+ # safe_find_or_create_by! was originally called here.
+ # We merely switched to `find_or_create_by!`
+ # rubocop: disable CodeReuse/ActiveRecord
project
.packages
.with_package_type(package_type)
- .safe_find_or_create_by!(name: name, version: version) do |package|
+ .find_or_create_by!(name: name, version: version) do |package|
package.status = params[:status] if params[:status]
package.creator = package_creator
add_build_info(package)
end
+ # rubocop: enable CodeReuse/ActiveRecord
end
def create_package!(package_type, attrs = {})
diff --git a/app/services/packages/generic/create_package_file_service.rb b/app/services/packages/generic/create_package_file_service.rb
index 42a191fb415..78c97000654 100644
--- a/app/services/packages/generic/create_package_file_service.rb
+++ b/app/services/packages/generic/create_package_file_service.rb
@@ -29,7 +29,8 @@ module Packages
package.update_column(:status, params[:status]) if params[:status] && params[:status] != package.status
- package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present?
+ package.create_build_infos!(params[:build])
+
package
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 c7ffd468864..b29adf4e11a 100644
--- a/app/services/packages/maven/find_or_create_package_service.rb
+++ b/app/services/packages/maven/find_or_create_package_service.rb
@@ -51,7 +51,7 @@ module Packages
.execute
end
- package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present?
+ package.create_build_infos!(params[:build])
ServiceResponse.success(payload: { package: package })
end
diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb
index 6ffe4f097f4..d1e47ad00a1 100644
--- a/app/services/packages/nuget/update_package_from_metadata_service.rb
+++ b/app/services/packages/nuget/update_package_from_metadata_service.rb
@@ -21,11 +21,7 @@ module Packages
try_obtain_lease do
@package_file.transaction do
- if use_new_package_file_updater?
- new_execute
- else
- legacy_execute
- end
+ process_package_update
end
end
rescue ActiveRecord::RecordInvalid => e
@@ -34,7 +30,7 @@ module Packages
private
- def new_execute
+ def process_package_update
package_to_destroy = nil
target_package = @package_file.package
@@ -50,36 +46,11 @@ module Packages
end
update_package(target_package)
-
::Packages::UpdatePackageFileService.new(@package_file, package_id: target_package.id, file_name: package_filename)
.execute
-
package_to_destroy&.destroy!
end
- def legacy_execute
- if existing_package
- package = link_to_existing_package
- elsif symbol_package?
- raise InvalidMetadataError, 'symbol package is invalid, matching package does not exist'
- else
- package = update_linked_package
- end
-
- update_package(package)
-
- # Updating file_name updates the path where the file is stored.
- # We must pass the file again so that CarrierWave can handle the update
- @package_file.update!(
- file_name: package_filename,
- file: @package_file.file
- )
- end
-
- def use_new_package_file_updater?
- ::Feature.enabled?(:packages_nuget_new_package_file_updater, @package_file.project, default_enabled: :yaml)
- end
-
def update_package(package)
return if symbol_package?
diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb
index f7d3d70aad6..8d33e6c1000 100644
--- a/app/services/pages/delete_service.rb
+++ b/app/services/pages/delete_service.rb
@@ -12,9 +12,6 @@ module Pages
PagesDomain.for_project(project).delete_all
DestroyPagesDeploymentsWorker.perform_async(project.id)
-
- # TODO: remove this call https://gitlab.com/gitlab-org/gitlab/-/issues/320775
- PagesRemoveWorker.perform_async(project.id) if ::Settings.pages.local_store.enabled
end
end
end
diff --git a/app/services/pages/legacy_storage_lease.rb b/app/services/pages/legacy_storage_lease.rb
deleted file mode 100644
index 1849def0183..00000000000
--- a/app/services/pages/legacy_storage_lease.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Pages
- module LegacyStorageLease
- extend ActiveSupport::Concern
-
- include ::ExclusiveLeaseGuard
-
- LEASE_TIMEOUT = 1.hour
-
- def lease_key
- "pages_legacy_storage:#{project.id}"
- end
-
- def lease_timeout
- LEASE_TIMEOUT
- end
- end
-end
diff --git a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
index 95c7107eb62..9c1671fbc15 100644
--- a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
+++ b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
@@ -2,10 +2,7 @@
module Pages
class MigrateLegacyStorageToDeploymentService
- ExclusiveLeaseTakenError = Class.new(StandardError)
-
include BaseServiceUtility
- include ::Pages::LegacyStorageLease
attr_reader :project
@@ -16,18 +13,6 @@ module Pages
end
def execute
- result = try_obtain_lease do
- execute_unsafe
- end
-
- raise ExclusiveLeaseTakenError, "Can't migrate pages for project #{project.id}: exclusive lease taken" if result.nil?
-
- result
- end
-
- private
-
- def execute_unsafe
zip_result = ::Pages::ZipDirectoryService.new(project.pages_path, ignore_invalid_entries: @ignore_invalid_entries).execute
if zip_result[:status] == :error
diff --git a/app/services/projects/batch_count_service.rb b/app/services/projects/batch_count_service.rb
index aec3b32da89..455c7211ab2 100644
--- a/app/services/projects/batch_count_service.rb
+++ b/app/services/projects/batch_count_service.rb
@@ -9,13 +9,19 @@ module Projects
@projects = projects
end
- def refresh_cache
- @projects.each do |project|
- service = count_service.new(project)
- unless service.count_stored?
- service.refresh_cache { global_count[project.id].to_i }
+ def refresh_cache_and_retrieve_data
+ count_services = @projects.map { |project| count_service.new(project) }
+ services_by_cache_key = count_services.index_by(&:cache_key)
+
+ results = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ Rails.cache.fetch_multi(*services_by_cache_key.keys) do |key|
+ service = services_by_cache_key[key]
+
+ global_count[service.project.id].to_i
end
end
+
+ results.transform_keys! { |cache_key| services_by_cache_key[cache_key].project }
end
def project_ids
diff --git a/app/services/projects/batch_forks_count_service.rb b/app/services/projects/batch_forks_count_service.rb
index d12772b40ff..6467744a435 100644
--- a/app/services/projects/batch_forks_count_service.rb
+++ b/app/services/projects/batch_forks_count_service.rb
@@ -5,21 +5,6 @@
# because the service use maps to retrieve the project ids
module Projects
class BatchForksCountService < Projects::BatchCountService
- def refresh_cache_and_retrieve_data
- count_services = @projects.map { |project| count_service.new(project) }
-
- values = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
- Rails.cache.fetch_multi(*(count_services.map { |ser| ser.cache_key } )) { |key| nil }
- end
-
- results_per_service = Hash[count_services.zip(values.values)]
- projects_to_refresh = results_per_service.select { |_k, value| value.nil? }
- projects_to_refresh = recreate_cache(projects_to_refresh)
-
- results_per_service.update(projects_to_refresh)
- results_per_service.transform_keys { |k| k.project }
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def global_count
@global_count ||= begin
@@ -33,13 +18,5 @@ module Projects
def count_service
::Projects::ForksCountService
end
-
- def recreate_cache(projects_to_refresh)
- projects_to_refresh.each_with_object({}) do |(service, _v), hash|
- count = global_count[service.project.id].to_i
- service.refresh_cache { count }
- hash[service] = count
- end
- end
end
end
diff --git a/app/services/projects/count_service.rb b/app/services/projects/count_service.rb
index 3cee80c7bbc..5daea3a2600 100644
--- a/app/services/projects/count_service.rb
+++ b/app/services/projects/count_service.rb
@@ -9,6 +9,8 @@ module Projects
# all caches.
VERSION = 1
+ attr_reader :project
+
def initialize(project)
@project = project
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index 302c047a65f..e717491b19d 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -12,6 +12,7 @@ module Projects
@import_data = @params.delete(:import_data)
@relations_block = @params.delete(:relations_block)
@default_branch = @params.delete(:default_branch)
+ @readme_template = @params.delete(:readme_template)
build_topics
end
@@ -89,10 +90,14 @@ module Projects
def after_create_actions
log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"")
- # Skip writing the config for project imports/forks because it
- # will always fail since the Git directory doesn't exist until
- # a background job creates it (see Project#add_import_job).
- @project.set_full_path unless @project.import?
+ if @project.import?
+ experiment(:combined_registration, user: current_user).track(:import_project)
+ else
+ # Skip writing the config for project imports/forks because it
+ # will always fail since the Git directory doesn't exist until
+ # a background job creates it (see Project#add_import_job).
+ @project.set_full_path
+ end
unless @project.gitlab_project_import?
@project.create_wiki unless skip_wiki?
@@ -149,12 +154,16 @@ module Projects
branch_name: @default_branch.presence || @project.default_branch_or_main,
commit_message: 'Initial commit',
file_path: 'README.md',
- file_content: experiment(:new_project_readme_content, namespace: @project.namespace).run_with(@project)
+ file_content: readme_content
}
Files::CreateService.new(@project, current_user, commit_attrs).execute
end
+ def readme_content
+ @readme_template.presence || experiment(:new_project_readme_content, namespace: @project.namespace).run_with(@project)
+ end
+
def skip_wiki?
!@project.feature_available?(:wiki, current_user) || @skip_wiki
end
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 3cee1b5975a..74b7d18f401 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -61,7 +61,8 @@ module Projects
# initializing the project, as that would cause a foreign key constraint
# exception.
relations_block: -> (project) { build_fork_network_member(project) },
- skip_disk_validation: skip_disk_validation
+ skip_disk_validation: skip_disk_validation,
+ external_authorization_classification_label: @project.external_authorization_classification_label
}
if @project.avatar.present? && @project.avatar.image?
diff --git a/app/services/projects/forks_count_service.rb b/app/services/projects/forks_count_service.rb
index 848d8d54104..ca85e2dc281 100644
--- a/app/services/projects/forks_count_service.rb
+++ b/app/services/projects/forks_count_service.rb
@@ -3,8 +3,6 @@
module Projects
# Service class for getting and caching the number of forks of a project.
class ForksCountService < Projects::CountService
- attr_reader :project
-
def cache_key_name
'forks_count'
end
diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb
index 01a5b617b46..19df0dc2c73 100644
--- a/app/services/projects/group_links/destroy_service.rb
+++ b/app/services/projects/group_links/destroy_service.rb
@@ -13,19 +13,15 @@ module Projects
end
group_link.destroy.tap do |link|
- if Feature.enabled?(:use_specialized_worker_for_project_auth_recalculation)
- refresh_project_authorizations_asynchronously(link.project)
+ refresh_project_authorizations_asynchronously(link.project)
- # Until we compare the inconsistency rates of the new specialized worker and
- # the old approach, we still run AuthorizedProjectsWorker
- # but with some delay and lower urgency as a safety net.
- link.group.refresh_members_authorized_projects(
- blocking: false,
- priority: UserProjectAccessChangedService::LOW_PRIORITY
- )
- else
- link.group.refresh_members_authorized_projects
- end
+ # Until we compare the inconsistency rates of the new specialized worker and
+ # the old approach, we still run AuthorizedProjectsWorker
+ # but with some delay and lower urgency as a safety net.
+ link.group.refresh_members_authorized_projects(
+ blocking: false,
+ priority: UserProjectAccessChangedService::LOW_PRIORITY
+ )
end
end
diff --git a/app/services/projects/move_deploy_keys_projects_service.rb b/app/services/projects/move_deploy_keys_projects_service.rb
index 51d84af249e..98ba5eb3f13 100644
--- a/app/services/projects/move_deploy_keys_projects_service.rb
+++ b/app/services/projects/move_deploy_keys_projects_service.rb
@@ -5,7 +5,7 @@ module Projects
def execute(source_project, remove_remaining_elements: true)
return unless super
- Project.transaction(requires_new: true) do
+ Project.transaction do
move_deploy_keys_projects
remove_remaining_deploy_keys_projects if remove_remaining_elements
diff --git a/app/services/projects/move_forks_service.rb b/app/services/projects/move_forks_service.rb
index 33f0bab12c9..a96cf4dd3ea 100644
--- a/app/services/projects/move_forks_service.rb
+++ b/app/services/projects/move_forks_service.rb
@@ -5,7 +5,7 @@ module Projects
def execute(source_project, remove_remaining_elements: true)
return unless super && source_project.fork_network
- Project.transaction(requires_new: true) do
+ Project.transaction do
move_fork_network_members
update_root_project
refresh_forks_count
diff --git a/app/services/projects/move_lfs_objects_projects_service.rb b/app/services/projects/move_lfs_objects_projects_service.rb
index 57a8d3d69c6..7107ecc6c95 100644
--- a/app/services/projects/move_lfs_objects_projects_service.rb
+++ b/app/services/projects/move_lfs_objects_projects_service.rb
@@ -5,7 +5,7 @@ module Projects
def execute(source_project, remove_remaining_elements: true)
return unless super
- Project.transaction(requires_new: true) do
+ Project.transaction do
move_lfs_objects_projects
remove_remaining_lfs_objects_project if remove_remaining_elements
diff --git a/app/services/projects/move_notification_settings_service.rb b/app/services/projects/move_notification_settings_service.rb
index efe06f158cc..fb84f10207d 100644
--- a/app/services/projects/move_notification_settings_service.rb
+++ b/app/services/projects/move_notification_settings_service.rb
@@ -5,7 +5,7 @@ module Projects
def execute(source_project, remove_remaining_elements: true)
return unless super
- Project.transaction(requires_new: true) do
+ Project.transaction do
move_notification_settings
remove_remaining_notification_settings if remove_remaining_elements
diff --git a/app/services/projects/move_project_authorizations_service.rb b/app/services/projects/move_project_authorizations_service.rb
index c95ad60ab5e..6ac173a20fc 100644
--- a/app/services/projects/move_project_authorizations_service.rb
+++ b/app/services/projects/move_project_authorizations_service.rb
@@ -9,7 +9,7 @@ module Projects
def execute(source_project, remove_remaining_elements: true)
return unless super
- Project.transaction(requires_new: true) do
+ Project.transaction do
move_project_authorizations
remove_remaining_authorizations if remove_remaining_elements
diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb
index 349953ff973..5f6a7dd09e1 100644
--- a/app/services/projects/move_project_group_links_service.rb
+++ b/app/services/projects/move_project_group_links_service.rb
@@ -9,7 +9,7 @@ module Projects
def execute(source_project, remove_remaining_elements: true)
return unless super
- Project.transaction(requires_new: true) do
+ Project.transaction do
move_group_links
remove_remaining_project_group_links if remove_remaining_elements
diff --git a/app/services/projects/move_project_members_service.rb b/app/services/projects/move_project_members_service.rb
index 9a1b7c6d1b6..011bd17c8cc 100644
--- a/app/services/projects/move_project_members_service.rb
+++ b/app/services/projects/move_project_members_service.rb
@@ -9,7 +9,7 @@ module Projects
def execute(source_project, remove_remaining_elements: true)
return unless super
- Project.transaction(requires_new: true) do
+ Project.transaction do
move_project_members
remove_remaining_members if remove_remaining_elements
diff --git a/app/services/projects/move_users_star_projects_service.rb b/app/services/projects/move_users_star_projects_service.rb
index 20121d429e2..5490448553f 100644
--- a/app/services/projects/move_users_star_projects_service.rb
+++ b/app/services/projects/move_users_star_projects_service.rb
@@ -9,7 +9,7 @@ module Projects
return unless user_stars.any?
- Project.transaction(requires_new: true) do
+ Project.transaction do
user_stars.update_all(project_id: @project.id)
Project.reset_counters @project.id, :users_star_projects
diff --git a/app/services/projects/open_issues_count_service.rb b/app/services/projects/open_issues_count_service.rb
index dc450311db2..8b7a418edf5 100644
--- a/app/services/projects/open_issues_count_service.rb
+++ b/app/services/projects/open_issues_count_service.rb
@@ -7,8 +7,12 @@ module Projects
include Gitlab::Utils::StrongMemoize
# Cache keys used to store issues count
- PUBLIC_COUNT_KEY = 'public_open_issues_count'
- TOTAL_COUNT_KEY = 'total_open_issues_count'
+ # TOTAL_COUNT_KEY includes confidential and hidden issues (admin)
+ # TOTAL_COUNT_WITHOUT_HIDDEN_KEY includes confidential issues but not hidden issues (reporter and above)
+ # PUBLIC_COUNT_WITHOUT_HIDDEN_KEY does not include confidential or hidden issues (guest)
+ TOTAL_COUNT_KEY = 'project_open_issues_including_hidden_count'
+ TOTAL_COUNT_WITHOUT_HIDDEN_KEY = 'project_open_issues_without_hidden_count'
+ PUBLIC_COUNT_WITHOUT_HIDDEN_KEY = 'project_open_public_issues_without_hidden_count'
def initialize(project, user = nil)
@user = user
@@ -16,16 +20,53 @@ module Projects
super(project)
end
+ # rubocop: disable CodeReuse/ActiveRecord
+ def refresh_cache(&block)
+ if block_given?
+ super(&block)
+ else
+ update_cache_for_key(total_count_cache_key) do
+ issues_with_hidden
+ end
+
+ update_cache_for_key(public_count_without_hidden_cache_key) do
+ issues_without_hidden_without_confidential
+ end
+
+ update_cache_for_key(total_count_without_hidden_cache_key) do
+ issues_without_hidden_with_confidential
+ end
+ end
+ end
+
+ private
+
+ def relation_for_count
+ self.class.query(@project, public_only: public_only?, include_hidden: include_hidden?)
+ end
+
def cache_key_name
- public_only? ? PUBLIC_COUNT_KEY : TOTAL_COUNT_KEY
+ if include_hidden?
+ TOTAL_COUNT_KEY
+ elsif public_only?
+ PUBLIC_COUNT_WITHOUT_HIDDEN_KEY
+ else
+ TOTAL_COUNT_WITHOUT_HIDDEN_KEY
+ end
+ end
+
+ def include_hidden?
+ user_is_admin?
end
def public_only?
!user_is_at_least_reporter?
end
- def relation_for_count
- self.class.query(@project, public_only: public_only?)
+ def user_is_admin?
+ strong_memoize(:user_is_admin) do
+ @user&.can_admin_all_resources?
+ end
end
def user_is_at_least_reporter?
@@ -34,46 +75,43 @@ module Projects
end
end
- def public_count_cache_key
- cache_key(PUBLIC_COUNT_KEY)
+ def total_count_without_hidden_cache_key
+ cache_key(TOTAL_COUNT_WITHOUT_HIDDEN_KEY)
+ end
+
+ def public_count_without_hidden_cache_key
+ cache_key(PUBLIC_COUNT_WITHOUT_HIDDEN_KEY)
end
def total_count_cache_key
cache_key(TOTAL_COUNT_KEY)
end
- # rubocop: disable CodeReuse/ActiveRecord
- def refresh_cache(&block)
- if block_given?
- super(&block)
- else
- count_grouped_by_confidential = self.class.query(@project, public_only: false).group(:confidential).count
- public_count = count_grouped_by_confidential[false] || 0
- total_count = public_count + (count_grouped_by_confidential[true] || 0)
+ def issues_with_hidden
+ self.class.query(@project, public_only: false, include_hidden: true).count
+ end
- update_cache_for_key(public_count_cache_key) do
- public_count
- end
+ def issues_without_hidden_without_confidential
+ self.class.query(@project, public_only: true, include_hidden: false).count
+ end
- update_cache_for_key(total_count_cache_key) do
- total_count
- end
- end
+ def issues_without_hidden_with_confidential
+ self.class.query(@project, public_only: false, include_hidden: false).count
end
- # rubocop: enable CodeReuse/ActiveRecord
- # We only show total issues count for reporters
- # which are allowed to view confidential issues
+ # We only show total issues count for admins, who are allowed to view hidden issues.
+ # We also only show issues count including confidential for reporters, who are allowed to view confidential issues.
# This will still show a discrepancy on issues number but should be less than before.
# Check https://gitlab.com/gitlab-org/gitlab-foss/issues/38418 description.
# rubocop: disable CodeReuse/ActiveRecord
- def self.query(projects, public_only: true)
- issues_filtered_by_type = Issue.opened.with_issue_type(Issue::TYPES_FOR_LIST)
- if public_only
- issues_filtered_by_type.public_only.where(project: projects)
+ def self.query(projects, public_only: true, include_hidden: false)
+ if include_hidden
+ Issue.opened.with_issue_type(Issue::TYPES_FOR_LIST).where(project: projects)
+ elsif public_only
+ Issue.public_only.opened.with_issue_type(Issue::TYPES_FOR_LIST).where(project: projects)
else
- issues_filtered_by_type.where(project: projects)
+ Issue.without_hidden.opened.with_issue_type(Issue::TYPES_FOR_LIST).where(project: projects)
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index 51b8e3c6c54..ef74f3e6e7a 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -94,6 +94,7 @@ module Projects
}
}
params[:error_tracking_setting_attributes][:token] = settings[:token] unless /\A\*+\z/.match?(settings[:token]) # Don't update token if we receive masked value
+ params[:error_tracking_setting_attributes][:integrated] = settings[:integrated] unless settings[:integrated].nil?
params
end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 074550e104d..27376173f07 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -20,8 +20,16 @@ module Projects
raise TransferError, s_('TransferProject|Please select a new namespace for your project.')
end
- unless allowed_transfer?(current_user, project)
- raise TransferError, s_('TransferProject|Transfer failed, please contact an admin.')
+ if @new_namespace.id == project.namespace_id
+ raise TransferError, s_('TransferProject|Project is already in this namespace.')
+ end
+
+ unless allowed_transfer_project?(current_user, project)
+ raise TransferError, s_("TransferProject|You don't have permission to transfer this project.")
+ end
+
+ unless allowed_to_transfer_to_namespace?(current_user, @new_namespace)
+ raise TransferError, s_("TransferProject|You don't have permission to transfer projects into that namespace.")
end
transfer(project)
@@ -121,11 +129,12 @@ module Projects
Milestones::TransferService.new(current_user, group, project).execute
end
- def allowed_transfer?(current_user, project)
- @new_namespace &&
- can?(current_user, :change_namespace, project) &&
- @new_namespace.id != project.namespace_id &&
- current_user.can?(:transfer_projects, @new_namespace)
+ def allowed_transfer_project?(current_user, project)
+ current_user.can?(:change_namespace, project)
+ end
+
+ def allowed_to_transfer_to_namespace?(current_user, namespace)
+ current_user.can?(:transfer_projects, namespace)
end
def update_namespace_and_visibility(to_namespace)
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index f96a6ee1255..dc75fe1014a 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -3,18 +3,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'
- # this has to be invalid group name,
- # as it shares the namespace with groups
- TMP_EXTRACT_PATH = '@pages.tmp'
-
# old deployment can be cached by pages daemon
# so we need to give pages daemon some time update cache
# 10 minutes is enough, but 30 feels safer
@@ -42,7 +33,6 @@ module Projects
validate_max_entries!
build.artifacts_file.use_file do |artifacts_path|
- deploy_to_legacy_storage(artifacts_path)
create_pages_deployment(artifacts_path, build)
success
end
@@ -78,70 +68,6 @@ module Projects
)
end
- def deploy_to_legacy_storage(artifacts_path)
- # path today used by one project can later be used by another
- # so we can't really scope this feature flag by project or group
- return unless ::Settings.pages.local_store.enabled
-
- return if Feature.enabled?(:skip_pages_deploy_to_legacy_storage, project, default_enabled: :yaml)
-
- # Create temporary directory in which we will extract the artifacts
- make_secure_tmp_dir(tmp_path) do |tmp_path|
- extract_archive!(artifacts_path, tmp_path)
-
- # Check if we did extract public directory
- archive_public_path = File.join(tmp_path, PUBLIC_DIR)
-
- raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path)
-
- validate_outdated_sha!
-
- deploy_page!(archive_public_path)
- end
- end
-
- def extract_archive!(artifacts_path, temp_path)
- if artifacts.ends_with?('.zip')
- extract_zip_archive!(artifacts_path, temp_path)
- else
- raise InvalidStateError, 'unsupported artifacts format'
- end
- end
-
- def extract_zip_archive!(artifacts_path, temp_path)
- SafeZip::Extract.new(artifacts_path)
- .extract(directories: [PUBLIC_DIR], to: temp_path)
- rescue SafeZip::Extract::Error => e
- raise FailedToExtractError, e.message
- 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)
- # 2. We move temporary public to be deployed public
- # 3. We remove previous public path
- FileUtils.mkdir_p(pages_path)
- begin
- FileUtils.move(public_path, previous_public_path)
- rescue StandardError
- end
- FileUtils.move(archive_public_path, public_path)
- ensure
- FileUtils.rm_r(previous_public_path, force: true)
- end
-
def create_pages_deployment(artifacts_path, build)
sha256 = build.job_artifacts_archive.file_sha256
@@ -165,22 +91,6 @@ module Projects
)
end
- def tmp_path
- @tmp_path ||= File.join(::Settings.pages.path, TMP_EXTRACT_PATH)
- end
-
- def pages_path
- @pages_path ||= project.pages_path
- end
-
- def public_path
- @public_path ||= File.join(pages_path, PUBLIC_DIR)
- end
-
- def previous_public_path
- @previous_public_path ||= File.join(pages_path, "#{PUBLIC_DIR}.#{SecureRandom.hex}")
- end
-
def ref
build.ref
end
@@ -216,20 +126,6 @@ module Projects
@pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed")
end
- def make_secure_tmp_dir(tmp_path)
- FileUtils.mkdir_p(tmp_path)
- path = Dir.mktmpdir(tmp_dir_prefix, tmp_path)
- begin
- yield(path)
- ensure
- FileUtils.remove_entry_secure(path)
- end
- end
-
- def tmp_dir_prefix
- "project-#{project.id}-build-#{build.id}-"
- end
-
def validate_state!
raise InvalidStateError, 'missing pages artifacts' unless build.artifacts?
raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata?
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index d6e7f165d72..b87564fcaef 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -105,6 +105,7 @@ module Projects
end
update_pages_config if changing_pages_related_config?
+ update_pending_builds if shared_runners_toggled?
end
def after_rename_service(project)
@@ -178,6 +179,16 @@ module Projects
params[:topic_list] ||= topic_list if topic_list
end
+
+ def update_pending_builds
+ update_params = { instance_runners_enabled: project.shared_runners_enabled }
+
+ ::Ci::UpdatePendingBuildService.new(project, update_params).execute
+ end
+
+ def shared_runners_toggled?
+ project.previous_changes.include?('shared_runners_enabled')
+ end
end
end
diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb
index 3e5122a1523..d0d0737fd66 100644
--- a/app/services/protected_branches/api_service.rb
+++ b/app/services/protected_branches/api_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ProtectedBranches
- class ApiService < BaseService
+ class ApiService < ProtectedBranches::BaseService
def create
::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute
end
diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb
new file mode 100644
index 00000000000..f48e02ab4b5
--- /dev/null
+++ b/app/services/protected_branches/base_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module ProtectedBranches
+ class BaseService < ::BaseService
+ # current_user - The user that performs the action
+ # params - A hash of parameters
+ def initialize(project, current_user = nil, params = {})
+ @project = project
+ @current_user = current_user
+ @params = params
+ end
+
+ def after_execute(*)
+ # overridden in EE::ProtectedBranches module
+ end
+ end
+end
diff --git a/app/services/protected_branches/create_service.rb b/app/services/protected_branches/create_service.rb
index 37083a4a9e4..dada449989a 100644
--- a/app/services/protected_branches/create_service.rb
+++ b/app/services/protected_branches/create_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ProtectedBranches
- class CreateService < BaseService
+ class CreateService < ProtectedBranches::BaseService
def execute(skip_authorization: false)
raise Gitlab::Access::AccessDeniedError unless skip_authorization || authorized?
diff --git a/app/services/protected_branches/destroy_service.rb b/app/services/protected_branches/destroy_service.rb
index dc177f0ac09..47332ace417 100644
--- a/app/services/protected_branches/destroy_service.rb
+++ b/app/services/protected_branches/destroy_service.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ProtectedBranches
- class DestroyService < BaseService
+ class DestroyService < ProtectedBranches::BaseService
def execute(protected_branch)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_protected_branch, protected_branch)
diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb
index 1815d92421e..1e70f2d9793 100644
--- a/app/services/protected_branches/update_service.rb
+++ b/app/services/protected_branches/update_service.rb
@@ -1,11 +1,17 @@
# frozen_string_literal: true
module ProtectedBranches
- class UpdateService < BaseService
+ class UpdateService < ProtectedBranches::BaseService
def execute(protected_branch)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :update_protected_branch, protected_branch)
- protected_branch.update(params)
+ old_merge_access_levels = protected_branch.merge_access_levels.map(&:clone)
+ old_push_access_levels = protected_branch.push_access_levels.map(&:clone)
+
+ if protected_branch.update(params)
+ after_execute(protected_branch: protected_branch, old_merge_access_levels: old_merge_access_levels, old_push_access_levels: old_push_access_levels)
+ end
+
protected_branch
end
end
diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb
index bac3fdf36da..96db00fbc1b 100644
--- a/app/services/repositories/changelog_service.rb
+++ b/app/services/repositories/changelog_service.rb
@@ -61,7 +61,7 @@ module Repositories
# rubocop: enable Metrics/ParameterLists
def execute
- config = Gitlab::Changelog::Config.from_git(@project)
+ config = Gitlab::Changelog::Config.from_git(@project, @user)
from = start_of_commit_range(config)
# For every entry we want to only include the merge request that
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index fe11820fb54..33faf2d6698 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -24,7 +24,7 @@ module Search
# rubocop: disable CodeReuse/ActiveRecord
def projects
- @projects ||= ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute.includes(:topics, :taggings)
+ @projects ||= ProjectsFinder.new(params: { non_archived: true }, current_user: current_user).execute.preload(:topics, :taggings)
end
def allowed_scopes
diff --git a/app/services/service_ping/submit_service.rb b/app/services/service_ping/submit_service.rb
index 09d1670fd1f..3417ce4f583 100644
--- a/app/services/service_ping/submit_service.rb
+++ b/app/services/service_ping/submit_service.rb
@@ -2,8 +2,9 @@
module ServicePing
class SubmitService
- PRODUCTION_URL = 'https://version.gitlab.com/usage_data'
- STAGING_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org/usage_data'
+ PRODUCTION_BASE_URL = 'https://version.gitlab.com'
+ STAGING_BASE_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org'
+ USAGE_DATA_PATH = 'usage_data'
METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes
percentage_notes leader_milestones instance_milestones percentage_milestones
@@ -41,6 +42,10 @@ module ServicePing
store_metrics(response)
end
+ def url
+ URI.join(base_url, USAGE_DATA_PATH)
+ end
+
private
def submit_payload(usage_data)
@@ -81,12 +86,8 @@ module ServicePing
end
# See https://gitlab.com/gitlab-org/gitlab/-/issues/233615 for details
- def url
- if Rails.env.production?
- PRODUCTION_URL
- else
- STAGING_URL
- end
+ def base_url
+ Rails.env.production? ? PRODUCTION_BASE_URL : STAGING_BASE_URL
end
end
end
diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
index 6836700a67d..a0d26e08341 100644
--- a/app/services/suggestions/apply_service.rb
+++ b/app/services/suggestions/apply_service.rb
@@ -33,7 +33,7 @@ module Suggestions
.update_all(commit_id: result[:result], applied: true)
Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter
- .track_apply_suggestion_action(user: current_user)
+ .track_apply_suggestion_action(user: current_user, suggestions: suggestion_set.suggestions)
end
def author
diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb
index c5f9fa1eee0..eb98ed57d55 100644
--- a/app/services/suggestions/create_service.rb
+++ b/app/services/suggestions/create_service.rb
@@ -28,7 +28,7 @@ module Suggestions
Gitlab::Database.main.bulk_insert('suggestions', rows) # rubocop:disable Gitlab/BulkInsert
end
- Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_add_suggestion_action(user: @note.author)
+ Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter.track_add_suggestion_action(note: @note)
end
end
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 56a6244eebf..e5080718b69 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -331,6 +331,10 @@ module SystemNoteService
::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project).log_resolving_alert(monitoring_tool)
end
+ def change_issue_type(issue, author)
+ ::SystemNotes::IssuablesService.new(noteable: issue, project: issue.project, author: author).change_issue_type
+ end
+
private
def merge_requests_service(noteable, project, author)
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index ae4f65e785c..62aead352aa 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -380,6 +380,12 @@ module SystemNotes
create_resource_state_event(status: 'closed', close_auto_resolve_prometheus_alert: true)
end
+ def change_issue_type
+ body = "changed issue type to #{noteable.issue_type.humanize(capitalize: false)}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'issue_type'))
+ end
+
private
def cross_reference_note_content(gfm_reference)
diff --git a/app/services/todos/destroy/design_service.rb b/app/services/todos/destroy/design_service.rb
new file mode 100644
index 00000000000..a375d659159
--- /dev/null
+++ b/app/services/todos/destroy/design_service.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Todos
+ module Destroy
+ # Service class for deleting todos that belongs to a deleted/archived design.
+ class DesignService
+ attr_reader :design_ids
+
+ def initialize(design_ids)
+ @design_ids = design_ids
+ end
+
+ def execute
+ todos.delete_all
+ end
+
+ private
+
+ def todos
+ Todo.for_target(deleted_designs.select(:design_id)).for_type(DesignManagement::Design)
+ end
+
+ def deleted_designs
+ DesignManagement::Action.by_design(design_ids).by_event(:deletion)
+ end
+ end
+ end
+end
diff --git a/app/services/users/ban_service.rb b/app/services/users/ban_service.rb
index 88e92ebff9b..959d4be3795 100644
--- a/app/services/users/ban_service.rb
+++ b/app/services/users/ban_service.rb
@@ -8,6 +8,10 @@ module Users
user.ban
end
+ def valid_state?(user)
+ user.active?
+ end
+
def action
:ban
end
diff --git a/app/services/users/banned_user_base_service.rb b/app/services/users/banned_user_base_service.rb
index 16041075941..a582816283a 100644
--- a/app/services/users/banned_user_base_service.rb
+++ b/app/services/users/banned_user_base_service.rb
@@ -8,6 +8,7 @@ module Users
def execute(user)
return permission_error unless allowed?
+ return state_error(user) unless valid_state?(user)
if update_user(user)
log_event(user)
@@ -22,6 +23,10 @@ module Users
attr_reader :current_user
+ def state_error(user)
+ error(_("You cannot %{action} %{state} users." % { action: action.to_s, state: user.state }), :forbidden)
+ end
+
def allowed?
can?(current_user, :admin_all_resources)
end
diff --git a/app/services/users/dismiss_group_callout_service.rb b/app/services/users/dismiss_group_callout_service.rb
new file mode 100644
index 00000000000..8afee6a8187
--- /dev/null
+++ b/app/services/users/dismiss_group_callout_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Users
+ class DismissGroupCalloutService < DismissUserCalloutService
+ private
+
+ def callout
+ current_user.find_or_initialize_group_callout(params[:feature_name], params[:group_id])
+ end
+ end
+end
diff --git a/app/services/users/dismiss_user_callout_service.rb b/app/services/users/dismiss_user_callout_service.rb
index f05c44186bb..96f3f3acb57 100644
--- a/app/services/users/dismiss_user_callout_service.rb
+++ b/app/services/users/dismiss_user_callout_service.rb
@@ -3,9 +3,15 @@
module Users
class DismissUserCalloutService < BaseContainerService
def execute
- current_user.find_or_initialize_callout(params[:feature_name]).tap do |callout|
- callout.update(dismissed_at: Time.current) if callout.valid?
+ callout.tap do |record|
+ record.update(dismissed_at: Time.current) if record.valid?
end
end
+
+ private
+
+ def callout
+ current_user.find_or_initialize_callout(params[:feature_name])
+ end
end
end
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index a471f55e644..515d7821416 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -14,23 +14,30 @@ module Users
def initialize(user)
@user = user
+ @ghost_user = User.ghost
end
def execute
transition = user.block_transition
- user.transaction do
- # Block the user before moving records to prevent a data race.
- # For example, if the user creates an issue after `migrate_issues`
- # runs and before the user is destroyed, the destroy will fail with
- # an exception.
- user.block
+ # Block the user before moving records to prevent a data race.
+ # For example, if the user creates an issue after `migrate_issues`
+ # runs and before the user is destroyed, the destroy will fail with
+ # an exception.
+ user.block
+ begin
+ user.transaction do
+ migrate_records
+ end
+ rescue Exception # rubocop:disable Lint/RescueException
# Reverse the user block if record migration fails
- if !migrate_records_in_transaction && transition
+ if transition
transition.rollback
user.save!
end
+
+ raise
end
user.reset
@@ -38,14 +45,6 @@ module Users
private
- def migrate_records_in_transaction
- user.transaction(requires_new: true) do
- @ghost_user = User.ghost
-
- migrate_records
- end
- end
-
def migrate_records
migrate_issues
migrate_merge_requests
diff --git a/app/services/users/reject_service.rb b/app/services/users/reject_service.rb
index 833c30d9427..459dd81b74d 100644
--- a/app/services/users/reject_service.rb
+++ b/app/services/users/reject_service.rb
@@ -7,8 +7,8 @@ module Users
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?
+ return error(_('You are not allowed to reject a user'), :forbidden) unless allowed?
+ return error(_('User does not have a pending request'), :conflict) unless user.blocked_pending_approval?
user.delete_async(deleted_by: current_user, params: { hard_delete: true })
@@ -18,7 +18,7 @@ module Users
log_event(user)
- success
+ success(message: 'Success', http_status: :ok)
end
private
diff --git a/app/services/users/unban_service.rb b/app/services/users/unban_service.rb
index 363783cf240..753a02fa752 100644
--- a/app/services/users/unban_service.rb
+++ b/app/services/users/unban_service.rb
@@ -5,7 +5,11 @@ module Users
private
def update_user(user)
- user.activate
+ user.unban
+ end
+
+ def valid_state?(user)
+ user.banned?
end
def action
diff --git a/app/services/wiki_pages/event_create_service.rb b/app/services/wiki_pages/event_create_service.rb
index ebfc2414f9e..1f613bec00e 100644
--- a/app/services/wiki_pages/event_create_service.rb
+++ b/app/services/wiki_pages/event_create_service.rb
@@ -10,11 +10,9 @@ module WikiPages
end
def execute(slug, page, action, event_fingerprint)
- event = Event.transaction do
- wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
+ wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
- ::EventCreateService.new.wiki_event(wiki_page_meta, author, action, event_fingerprint)
- end
+ event = ::EventCreateService.new.wiki_event(wiki_page_meta, author, action, event_fingerprint)
ServiceResponse.success(payload: { event: event })
rescue ::EventCreateService::IllegalActionError, ::ActiveRecord::ActiveRecordError => e
diff --git a/app/validators/gitlab/utils/zoom_url_validator.rb b/app/validators/gitlab/utils/zoom_url_validator.rb
deleted file mode 100644
index 57e30dcefa6..00000000000
--- a/app/validators/gitlab/utils/zoom_url_validator.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-# Gitlab::Utils::ZoomUrlValidator
-#
-# Custom validator for zoom urls
-#
-module Gitlab
- module Utils
- class ZoomUrlValidator < ActiveModel::EachValidator
- ALLOWED_SCHEMES = %w(https).freeze
-
- def validate_each(record, attribute, value)
- links_count = Gitlab::ZoomLinkExtractor.new(value).links.size
- valid = Gitlab::UrlSanitizer.valid?(value, allowed_schemes: ALLOWED_SCHEMES)
-
- return if links_count == 1 && valid
-
- record.errors.add(:url, 'must contain one valid Zoom URL')
- end
- end
- end
-end
diff --git a/app/validators/gitlab/zoom_url_validator.rb b/app/validators/gitlab/zoom_url_validator.rb
new file mode 100644
index 00000000000..c752cec07c2
--- /dev/null
+++ b/app/validators/gitlab/zoom_url_validator.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Gitlab
+ # Gitlab::Utils::ZoomUrlValidator
+ #
+ # Custom validator for zoom urls
+ #
+ # @example usage
+ # validates :url, 'gitlab/zoom_url': true
+ class ZoomUrlValidator < ActiveModel::EachValidator
+ ALLOWED_SCHEMES = %w(https).freeze
+
+ def validate_each(record, attribute, value)
+ links_count = Gitlab::ZoomLinkExtractor.new(value).links.size
+ valid = Gitlab::UrlSanitizer.valid?(value, allowed_schemes: ALLOWED_SCHEMES)
+
+ return if links_count == 1 && valid
+
+ record.errors.add(:url, 'must contain one valid Zoom URL')
+ end
+ end
+end
diff --git a/app/validators/json_schemas/cluster_agent_authorization_configuration.json b/app/validators/json_schemas/cluster_agent_authorization_configuration.json
new file mode 100644
index 00000000000..f3de0b7043b
--- /dev/null
+++ b/app/validators/json_schemas/cluster_agent_authorization_configuration.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Cluster Agent configuration for an authorized project or group",
+ "type": "object",
+ "additionalProperties": true
+}
diff --git a/app/validators/json_schemas/dast_profile_schedule_cadence.json b/app/validators/json_schemas/dast_profile_schedule_cadence.json
new file mode 100644
index 00000000000..5583acfa5af
--- /dev/null
+++ b/app/validators/json_schemas/dast_profile_schedule_cadence.json
@@ -0,0 +1,31 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "description": "Dast profile schedule cadence schema",
+ "type": "object",
+ "anyOf": [
+ {
+ "properties": {
+ "unit": { "enum": ["day"] },
+ "duration": { "enum": [1] }
+ }
+ },
+ {
+ "properties": {
+ "unit": { "enum": ["week"] },
+ "duration": { "enum": [1] }
+ }
+ },
+ {
+ "properties": {
+ "unit": { "enum": ["month"] },
+ "duration": { "enum": [1, 3 ,6] }
+ }
+ },
+ {
+ "properties": {
+ "unit": { "enum": ["year"] },
+ "duration": { "enum": [1] }
+ }
+ }
+ ]
+}
diff --git a/app/validators/json_schemas/error_tracking_event_payload.json b/app/validators/json_schemas/error_tracking_event_payload.json
index 19abde7de08..52efcf6800c 100644
--- a/app/validators/json_schemas/error_tracking_event_payload.json
+++ b/app/validators/json_schemas/error_tracking_event_payload.json
@@ -2,161 +2,15 @@
"description": "Error tracking event payload",
"type": "object",
"required": [],
- "modules": {
- "type": "object"
- },
"properties": {
- "event_id": {
- "type": "string"
- },
- "level": {
- "type": "string"
- },
- "timestamp": {
- "type": "string"
- },
- "release": {
- "type": "string"
- },
"environment": {
"type": "string"
},
- "server_name": {
- "type": "string"
- },
- "message": {
- "type": "string"
- },
- "user": {
- "type": "object",
- "required": [],
- "properties": {}
- },
- "tags": {
- "type": "object",
- "required": [],
- "properties": {
- "request_id": {
- "type": "string"
- }
- }
- },
- "contexts": {
- "type": "object",
- "required": [],
- "properties": {
- "os": {
- "type": "object",
- "required": [],
- "properties": {
- "name": {
- "type": "string"
- },
- "version": {
- "type": "string"
- },
- "build": {
- "type": "string"
- },
- "kernel_version": {
- "type": "string"
- }
- }
- },
- "runtime": {
- "type": "object",
- "required": [],
- "properties": {
- "name": {
- "type": "string"
- },
- "version": {
- "type": "string"
- }
- }
- },
- "trace": {
- "type": "object"
- }
- }
- },
- "fingerprint": {
- "type": "array",
- "items": {
- "type": "string"
- }
- },
- "breadcrumbs": {
- "type": "object",
- "required": [],
- "properties": {
- "values": {
- "type": "array",
- "items": {
- "type": "object",
- "required": [],
- "properties": {
- "category": {
- "type": "string"
- },
- "data": {
- "type": "object"
- },
- "message": {
- "type": "string"
- },
- "timestamp": {
- "type": "number"
- }
- }
- }
- }
- }
- },
- "transaction": {
- "type": "string"
- },
"platform": {
"type": "string"
},
"sdk": {
- "type": "object",
- "required": [],
- "properties": {
- "name": {
- "type": "string"
- },
- "version": {
- "type": "string"
- }
- }
- },
- "request": {
- "type": "object",
- "required": [],
- "properties": {
- "url": {
- "type": "string"
- },
- "method": {
- "type": "string"
- },
- "headers": {
- "type": "object"
- },
- "env": {
- "type": "object",
- "required": [],
- "properties": {
- "SERVER_NAME": {
- "type": "string"
- },
- "SERVER_PORT": {
- "type": "string"
- }
- }
- }
- }
+ "type": "object"
},
"exception": {
"type": "object",
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index ee97a678aaa..fea116bd419 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -41,7 +41,7 @@
= f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold'
= f.text_field :default_artifacts_expire_in, class: 'form-control gl-form-input'
.form-text.text-muted
- = html_escape(_("The default expiration time for job artifacts. 0 for unlimited. The default unit is in seconds, but you can use other units, for example %{code_open}4 mins 2 sec%{code_close}, %{code_open}2h42min%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+ = html_escape(_("Set the default expiration time for job artifacts in all projects. Set to %{code_open}0%{code_close} to never expire artifacts by default. If no unit is written, it defaults to seconds. For example, these are all equivalent: %{code_open}3600%{code_close}, %{code_open}60 minutes%{code_close}, or %{code_open}one hour%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
= link_to _('Learn more.'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
.form-group
.form-check
diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml
index c44bad132bd..c83e28d7f0b 100644
--- a/app/views/admin/application_settings/_eks.html.haml
+++ b/app/views/admin/application_settings/_eks.html.haml
@@ -17,20 +17,20 @@
.form-check
= f.check_box :eks_integration_enabled, class: 'form-check-input'
= f.label :eks_integration_enabled, class: 'form-check-label' do
- Enable Amazon EKS integration
+ = _('Enable Amazon EKS integration')
.form-group
- = f.label :eks_account_id, 'Account ID', class: 'label-bold'
+ = f.label :eks_account_id, _('Account ID'), class: 'label-bold'
= f.text_field :eks_account_id, class: 'form-control gl-form-input'
.form-group
- = f.label :eks_access_key_id, 'Access key ID', class: 'label-bold'
+ = f.label :eks_access_key_id, _('Access key ID'), class: 'label-bold'
= f.text_field :eks_access_key_id, class: 'form-control gl-form-input'
.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.label :eks_secret_access_key, _('Secret access key'), class: 'label-bold'
= f.password_field :eks_secret_access_key, autocomplete: 'off', class: 'form-control gl-form-input'
.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-confirm"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 1c35250644d..073c0bf619d 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -33,4 +33,12 @@
.form-text.text-muted
= _('Send emails to help guide new users through the onboarding process.')
+ .form-group
+ .form-check
+ = f.check_box :user_deactivation_emails_enabled, class: 'form-check-input'
+ = f.label :user_deactivation_emails_enabled, class: 'form-check-label' do
+ = _('Enable user deactivation emails')
+ .form-text.text-muted
+ = _('Send emails to users upon account deactivation.')
+
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
index abd182027b1..23484eaec32 100644
--- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -47,4 +47,4 @@
.form-group
= f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold'
= f.text_field :external_authorization_service_default_label, class: 'form-control gl-form-input'
- = f.submit 'Save changes', class: "gl-button btn btn-confirm"
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/_files_limits.html.haml b/app/views/admin/application_settings/_files_limits.html.haml
new file mode 100644
index 00000000000..9cd12fa1caa
--- /dev/null
+++ b/app/views/admin/application_settings/_files_limits.html.haml
@@ -0,0 +1,34 @@
+= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-files-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ %legend.h5.gl-border-none
+ = _('Unauthenticated API request rate limit')
+ .form-group
+ = f.gitlab_ui_checkbox_component :throttle_unauthenticated_files_api_enabled,
+ _('Enable unauthenticated API request rate limit'),
+ help_text: _('Helps reduce request volume (e.g. from crawlers or abusive bots)'),
+ checkbox_options: { data: { qa_selector: 'throttle_unauthenticated_files_api_checkbox' } }
+ .form-group
+ = f.label :throttle_unauthenticated_files_api_requests_per_period, 'Max unauthenticated API requests per period per IP', class: 'label-bold'
+ = f.number_field :throttle_unauthenticated_files_api_requests_per_period, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :throttle_unauthenticated_files_api_period_in_seconds, 'Unauthenticated API rate limit period in seconds', class: 'label-bold'
+ = f.number_field :throttle_unauthenticated_files_api_period_in_seconds, class: 'form-control gl-form-input'
+
+ %fieldset
+ %legend.h5.gl-border-none
+ = _('Authenticated API request rate limit')
+ .form-group
+ = f.gitlab_ui_checkbox_component :throttle_authenticated_files_api_enabled,
+ _('Enable authenticated API request rate limit'),
+ help_text: _('Helps reduce request volume (e.g. from crawlers or abusive bots)'),
+ checkbox_options: { data: { qa_selector: 'throttle_authenticated_files_api_checkbox' } }
+ .form-group
+ = f.label :throttle_authenticated_files_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold'
+ = f.number_field :throttle_authenticated_files_api_requests_per_period, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :throttle_authenticated_files_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold'
+ = f.number_field :throttle_authenticated_files_api_period_in_seconds, class: 'form-control gl-form-input'
+
+ = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml
index 398064f9730..52833b5cfc2 100644
--- a/app/views/admin/application_settings/_floc.html.haml
+++ b/app/views/admin/application_settings/_floc.html.haml
@@ -19,4 +19,4 @@
.form-check
= f.check_box :floc_enabled, class: 'form-check-input'
= f.label :floc_enabled, s_('FloC|Enable FloC (Federated Learning of Cohorts)'), class: 'form-check-label'
- = f.submit s_('Save changes'), class: 'gl-button btn btn-confirm'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_git_lfs_limits.html.haml b/app/views/admin/application_settings/_git_lfs_limits.html.haml
new file mode 100644
index 00000000000..de5a2ceaa3d
--- /dev/null
+++ b/app/views/admin/application_settings/_git_lfs_limits.html.haml
@@ -0,0 +1,21 @@
+= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-git-lfs-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ %h5
+ = _('Authenticated Git LFS request rate limit')
+ .form-group
+ .form-check
+ = f.check_box :throttle_authenticated_git_lfs_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_git_lfs_checkbox' }
+ = f.label :throttle_authenticated_git_lfs_enabled, class: 'form-check-label gl-font-weight-bold' do
+ = _('Enable authenticated Git LFS request rate limit')
+ %span.form-text.gl-text-gray-600
+ = _('Helps reduce request volume (for example, from crawlers or abusive bots)')
+ .form-group
+ = f.label :throttle_authenticated_git_lfs_requests_per_period, _('Max authenticated Git LFS requests per period per user'), class: 'gl-font-weight-bold'
+ = f.number_field :throttle_authenticated_git_lfs_requests_per_period, class: 'form-control gl-form-input'
+ .form-group
+ = f.label :throttle_authenticated_git_lfs_period_in_seconds, _('Authenticated Git LFS rate limit period in seconds'), class: 'gl-font-weight-bold'
+ = f.number_field :throttle_authenticated_git_lfs_period_in_seconds, class: 'form-control gl-form-input'
+
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml
index c08b41e8c55..8f99a07b87c 100644
--- a/app/views/admin/application_settings/_gitpod.html.haml
+++ b/app/views/admin/application_settings/_gitpod.html.haml
@@ -27,4 +27,4 @@
= s_('Gitpod|The URL to your Gitpod instance configured to read your GitLab projects, such as https://gitpod.example.com.')
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('integration/gitpod', anchor: 'enable-gitpod-in-your-user-settings') }
= s_('Gitpod|To use the integration, each user must also enable Gitpod on their GitLab account. %{link_start}How do I enable it?%{link_end} ').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
- = f.submit s_('Save changes'), class: 'gl-button btn btn-confirm'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml
index 820c11279d5..bc4a1577f90 100644
--- a/app/views/admin/application_settings/_import_export_limits.html.haml
+++ b/app/views/admin/application_settings/_import_export_limits.html.haml
@@ -2,33 +2,37 @@
= form_errors(@application_setting)
%fieldset
+ = html_escape(_("Set any rate limit to %{code_open}0%{code_close} to disable the limit.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
+
+
+ %fieldset
.form-group
- = f.label :project_import_limit, _('Max Project Import requests per minute per user'), class: 'label-bold'
+ = f.label :project_import_limit, _('Maximum project import requests per minute'), class: 'label-bold'
= f.number_field :project_import_limit, class: 'form-control gl-form-input'
%fieldset
.form-group
- = f.label :project_export_limit, _('Max Project Export requests per minute per user'), class: 'label-bold'
+ = f.label :project_export_limit, _('Maximum project export requests per minute'), class: 'label-bold'
= f.number_field :project_export_limit, class: 'form-control gl-form-input'
%fieldset
.form-group
- = f.label :project_download_export_limit, _('Max Project Export Download requests per minute per user'), class: 'label-bold'
+ = f.label :project_download_export_limit, _('Maximum project export download requests per minute'), class: 'label-bold'
= f.number_field :project_download_export_limit, class: 'form-control gl-form-input'
%fieldset
.form-group
- = f.label :group_import_limit, _('Max Group Import requests per minute per user'), class: 'label-bold'
+ = f.label :group_import_limit, _('Maximum group import requests per minute'), class: 'label-bold'
= f.number_field :group_import_limit, class: 'form-control gl-form-input'
%fieldset
.form-group
- = f.label :group_export_limit, _('Max Group Export requests per minute per user'), class: 'label-bold'
+ = f.label :group_export_limit, _('Maximum group export requests per minute'), class: 'label-bold'
= f.number_field :group_export_limit, class: 'form-control gl-form-input'
%fieldset
.form-group
- = f.label :group_download_export_limit, _('Max Group Export Download requests per minute per user'), class: 'label-bold'
+ = f.label :group_download_export_limit, _('Maximum group export download requests per minute'), class: 'label-bold'
= f.number_field :group_download_export_limit, class: 'form-control gl-form-input'
- = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml
index 34c40892467..8832bc02056 100644
--- a/app/views/admin/application_settings/_initial_branch_name.html.haml
+++ b/app/views/admin/application_settings/_initial_branch_name.html.haml
@@ -1,4 +1,4 @@
-= form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
+= form_for @application_setting, url: repository_admin_application_settings_path(anchor: 'js-default-branch-name'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
- fallback_branch_name = "<code>#{Gitlab::DefaultBranch.value}</code>"
diff --git a/app/views/admin/application_settings/_ip_limits.html.haml b/app/views/admin/application_settings/_ip_limits.html.haml
index e584aaf9880..4362ae9cb9b 100644
--- a/app/views/admin/application_settings/_ip_limits.html.haml
+++ b/app/views/admin/application_settings/_ip_limits.html.haml
@@ -1,60 +1,69 @@
-= form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-ip-limits-settings'), html: { class: 'fieldset-form' } do |f|
+= gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-ip-limits-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset
- %h5
- = _('Unauthenticated request rate limit')
+ = _("Rate limits can help reduce request volume (like from crawlers or abusive bots).")
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :throttle_unauthenticated_api_enabled,
+ _("Enable unauthenticated API request rate limit"),
+ checkbox_options: { data: { qa_selector: 'throttle_unauthenticated_api_checkbox' } },
+ label_options: { class: 'label-bold' }
.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 label-bold' do
- = _("Enable unauthenticated request rate limit")
- %span.form-text.text-muted
- = _("Helps reduce request volume (e.g. from crawlers or abusive bots)")
+ = f.label :throttle_unauthenticated_api_requests_per_period, _('Maximum unauthenticated API requests per rate limit period per IP'), class: 'label-bold'
+ = f.number_field :throttle_unauthenticated_api_requests_per_period, class: 'form-control gl-form-input'
.form-group
- = f.label :throttle_unauthenticated_requests_per_period, _('Max unauthenticated requests per period per IP'), class: 'label-bold'
+ = f.label :throttle_unauthenticated_api_period_in_seconds, _('Unauthenticated API rate limit period in seconds'), class: 'label-bold'
+ = f.number_field :throttle_unauthenticated_api_period_in_seconds, class: 'form-control gl-form-input'
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :throttle_unauthenticated_enabled,
+ _("Enable unauthenticated web request rate limit"),
+ checkbox_options: { data: { qa_selector: 'throttle_unauthenticated_web_checkbox' } },
+ label_options: { class: 'label-bold' }
+ .form-group
+ = f.label :throttle_unauthenticated_requests_per_period, _('Maximum unauthenticated web requests per rate limit period per IP'), class: 'label-bold'
= f.number_field :throttle_unauthenticated_requests_per_period, class: 'form-control gl-form-input'
.form-group
- = f.label :throttle_unauthenticated_period_in_seconds, _('Unauthenticated rate limit period in seconds'), class: 'label-bold'
+ = f.label :throttle_unauthenticated_period_in_seconds, _('Unauthenticated web rate limit period in seconds'), class: 'label-bold'
= f.number_field :throttle_unauthenticated_period_in_seconds, class: 'form-control gl-form-input'
- %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 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 authenticated API requests per period per user'), class: 'label-bold'
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :throttle_authenticated_api_enabled,
+ _("Enable authenticated API request rate limit"),
+ checkbox_options: { data: { qa_selector: 'throttle_authenticated_api_checkbox' }},
+ label_options: { class: 'label-bold' }
+ .form-group
+ = f.label :throttle_authenticated_api_requests_per_period, _('Maximum authenticated API requests per rate limit period per user'), class: 'label-bold'
= f.number_field :throttle_authenticated_api_requests_per_period, class: 'form-control gl-form-input'
.form-group
= f.label :throttle_authenticated_api_period_in_seconds, _('Authenticated API rate limit period in seconds'), class: 'label-bold'
= f.number_field :throttle_authenticated_api_period_in_seconds, class: 'form-control gl-form-input'
- %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 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 authenticated web requests per period per user'), class: 'label-bold'
+
+ %fieldset
+ .form-group
+ = f.gitlab_ui_checkbox_component :throttle_authenticated_web_enabled,
+ _("Enable authenticated web request rate limit"),
+ checkbox_options: { data: { qa_selector: 'throttle_authenticated_web_checkbox' } },
+ label_options: { class: 'label-bold' }
+ .form-group
+ = f.label :throttle_authenticated_web_requests_per_period, _('Maximum authenticated web requests per rate limit period per user'), class: 'label-bold'
= f.number_field :throttle_authenticated_web_requests_per_period, class: 'form-control gl-form-input'
.form-group
= f.label :throttle_authenticated_web_period_in_seconds, _('Authenticated web rate limit period in seconds'), class: 'label-bold'
= f.number_field :throttle_authenticated_web_period_in_seconds, class: 'form-control gl-form-input'
- %hr
- %h5
+
+ %fieldset
+ %legend.h5.gl-border-none
= _('Response text')
.form-group
= f.label :rate_limiting_response_text, class: 'label-bold' do
- = _('A plain-text response to show to clients that hit the rate limit.')
+ = _('Plain-text response to send to clients that hit a rate limit')
= f.text_area :rate_limiting_response_text, placeholder: ::Gitlab::Throttle::DEFAULT_RATE_LIMITING_RESPONSE_TEXT, class: 'form-control gl-form-input', rows: 5
+ .form-text.text-muted
+ = html_escape(_("If blank, defaults to %{code_open}Retry later%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe }
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_issue_limits.html.haml b/app/views/admin/application_settings/_issue_limits.html.haml
index 0e1ba8c9c88..663e1485749 100644
--- a/app/views/admin/application_settings/_issue_limits.html.haml
+++ b/app/views/admin/application_settings/_issue_limits.html.haml
@@ -6,4 +6,4 @@
= f.label :issues_create_limit, 'Max requests per minute per user', class: 'label-bold'
= f.number_field :issues_create_limit, class: 'form-control gl-form-input'
- = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_mailgun.html.haml b/app/views/admin/application_settings/_mailgun.html.haml
index 6204f7df5dc..40b4d5cac6d 100644
--- a/app/views/admin/application_settings/_mailgun.html.haml
+++ b/app/views/admin/application_settings/_mailgun.html.haml
@@ -1,5 +1,3 @@
-- return unless Feature.enabled?(:mailgun_events_receiver)
-
- expanded = integration_expanded?('mailgun_')
%section.settings.as-mailgun.no-animate#js-mailgun-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/admin/application_settings/_note_limits.html.haml b/app/views/admin/application_settings/_note_limits.html.haml
index d50b3395d8f..eb6122f244a 100644
--- a/app/views/admin/application_settings/_note_limits.html.haml
+++ b/app/views/admin/application_settings/_note_limits.html.haml
@@ -3,10 +3,13 @@
%fieldset
.form-group
- = f.label :notes_create_limit, _('Max requests per minute per user'), class: 'label-bold'
+ = f.label :notes_create_limit, _('Maximum requests per minute'), class: 'label-bold'
= f.number_field :notes_create_limit, class: 'form-control gl-form-input'
.form-group
- = f.label :notes_create_limit_allowlist, _('List of users to be excluded from the limit'), class: 'label-bold'
+ = f.label :notes_create_limit_allowlist, _('Users to exclude from the rate limit'), class: 'label-bold'
= f.text_area :notes_create_limit_allowlist_raw, placeholder: 'username1, username2', class: 'form-control gl-form-input', rows: 5
+ .form-text.text-muted
+ = _('Comma-separated list of users allowed to exceed the rate limit.')
+
= f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml
index d8d105293a1..142a3fbfbd0 100644
--- a/app/views/admin/application_settings/_outbound.html.haml
+++ b/app/views/admin/application_settings/_outbound.html.haml
@@ -27,4 +27,4 @@
%span.form-text.text-muted
= _('Resolves IP addresses once and uses them to submit requests')
- = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_package_registry_limits.html.haml b/app/views/admin/application_settings/_package_registry_limits.html.haml
index b1dfd04c55e..8769171c9e0 100644
--- a/app/views/admin/application_settings/_package_registry_limits.html.haml
+++ b/app/views/admin/application_settings/_package_registry_limits.html.haml
@@ -2,36 +2,31 @@
= form_errors(@application_setting)
%fieldset
- %h5
- = _('Unauthenticated API request rate limit')
+ = _("The package registry rate limits can help reduce request volume (like from crawlers or abusive bots).")
+
+ %fieldset
.form-group
.form-check
= f.check_box :throttle_unauthenticated_packages_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_unauthenticated_packages_api_checkbox' }
= f.label :throttle_unauthenticated_packages_api_enabled, class: 'form-check-label label-bold' do
= _('Enable unauthenticated 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_unauthenticated_packages_api_requests_per_period, 'Max unauthenticated API requests per period per IP', class: 'label-bold'
+ = f.label :throttle_unauthenticated_packages_api_requests_per_period, _('Maximum unauthenticated API requests per rate limit period per IP'), class: 'label-bold'
= f.number_field :throttle_unauthenticated_packages_api_requests_per_period, class: 'form-control gl-form-input'
.form-group
- = f.label :throttle_unauthenticated_packages_api_period_in_seconds, 'Unauthenticated API rate limit period in seconds', class: 'label-bold'
+ = f.label :throttle_unauthenticated_packages_api_period_in_seconds, _('Unauthenticated API rate limit period in seconds'), class: 'label-bold'
= f.number_field :throttle_unauthenticated_packages_api_period_in_seconds, class: 'form-control gl-form-input'
%hr
- %h5
- = _('Authenticated API request rate limit')
.form-group
.form-check
= f.check_box :throttle_authenticated_packages_api_enabled, class: 'form-check-input', data: { qa_selector: 'throttle_authenticated_packages_api_checkbox' }
= f.label :throttle_authenticated_packages_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_packages_api_requests_per_period, 'Max authenticated API requests per period per user', class: 'label-bold'
+ = f.label :throttle_authenticated_packages_api_requests_per_period, _('Maximum authenticated API requests per rate limit period per user'), class: 'label-bold'
= f.number_field :throttle_authenticated_packages_api_requests_per_period, class: 'form-control gl-form-input'
.form-group
- = f.label :throttle_authenticated_packages_api_period_in_seconds, 'Authenticated API rate limit period in seconds', class: 'label-bold'
+ = f.label :throttle_authenticated_packages_api_period_in_seconds, _('Authenticated API rate limit period in seconds'), class: 'label-bold'
= f.number_field :throttle_authenticated_packages_api_period_in_seconds, class: 'form-control gl-form-input'
- = f.submit 'Save changes', class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
diff --git a/app/views/admin/application_settings/_protected_paths.html.haml b/app/views/admin/application_settings/_protected_paths.html.haml
index faa675f211d..04b42f42014 100644
--- a/app/views/admin/application_settings/_protected_paths.html.haml
+++ b/app/views/admin/application_settings/_protected_paths.html.haml
@@ -28,4 +28,4 @@
= _('All paths are relative to the GitLab URL. Do not include %{relative_url_link_start}relative URL%{relative_url_link_end}.').html_safe % { relative_url_link_start: relative_url_link_start, relative_url_link_end: '</a>'.html_safe }
= f.text_area :protected_paths_raw, placeholder: '/users/sign_in,/users/password', class: 'form-control gl-form-input', rows: 10
- = f.submit 'Save changes', class: 'gl-button btn btn-confirm'
+ = f.submit _('Save changes'), class: 'gl-button btn btn-confirm'
diff --git a/app/views/admin/application_settings/_sidekiq_job_limits.html.haml b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml
new file mode 100644
index 00000000000..eaf4bbf4702
--- /dev/null
+++ b/app/views/admin/application_settings/_sidekiq_job_limits.html.haml
@@ -0,0 +1,21 @@
+= form_for @application_setting, url: preferences_admin_application_settings_path(anchor: 'js-sidekiq-job-limits-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :sidekiq_job_limiter_mode, _('Limiting mode'), class: 'label-bold'
+ = f.select :sidekiq_job_limiter_mode, sidekiq_job_limiter_modes_for_select, {}, class: 'form-control'
+ .form-text.text-muted
+ = sidekiq_job_limiter_mode_help_text
+ .form-group
+ = f.label :sidekiq_job_limiter_compression_threshold_bytes, _('Sidekiq job compression threshold (bytes)'), class: 'label-bold'
+ = f.number_field :sidekiq_job_limiter_compression_threshold_bytes, class: 'form-control gl-form-input'
+ .form-text.text-muted
+ = _('Threshold in bytes at which to compress Sidekiq job arguments.')
+ .form-group
+ = f.label :sidekiq_job_limiter_limit_bytes, _('Sidekiq job size limit (bytes)'), class: 'label-bold'
+ = f.number_field :sidekiq_job_limiter_limit_bytes, class: 'form-control gl-form-input'
+ .form-text.text-muted
+ = _("Threshold in bytes at which to reject Sidekiq jobs. Set this to 0 to if you don't want to limit Sidekiq jobs.")
+
+ = f.submit _('Save changes'), class: "gl-button btn btn-confirm"
diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml
index 0e9dcb23dcb..8dff2bc36cb 100644
--- a/app/views/admin/application_settings/network.html.haml
+++ b/app/views/admin/application_settings/network.html.haml
@@ -16,24 +16,49 @@
%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'ip_limits_content' } }
.settings-header
%h4
- = _('User and IP Rate Limits')
+ = _('User and IP rate limits')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Configure limits for web and API requests.')
+ = _('Set limits for web and API requests.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/user_and_ip_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'ip_limits'
%section.settings.as-packages-limits.no-animate#js-packages-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'packages_limits_content' } }
.settings-header
%h4
- = _('Package Registry Rate Limits')
+ = _('Package registry rate limits')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Configure specific limits for Packages API requests that supersede the general user and IP rate limits.')
+ = _('Set rate limits for package registry API requests that supersede the general user and IP rate limits.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/package_registry_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'package_registry_limits'
+- if Feature.enabled?(:files_api_throttling, default_enabled: :yaml)
+ %section.settings.as-files-limits.no-animate#js-files-limits-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'files-limits-settings' } }
+ .settings-header
+ %h4
+ = _('Files API Rate Limits')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Configure specific limits for Files API requests that supersede the general user and IP rate limits.')
+ .settings-content
+ = render 'files_limits'
+
+%section.settings.as-git-lfs-limits.no-animate#js-git-lfs-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'git_lfs_limits_content' } }
+ .settings-header
+ %h4
+ = _('Git LFS Rate Limits')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Configure specific limits for Git LFS requests that supersede the general user and IP rate limits.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/git_lfs_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ = render 'git_lfs_limits'
%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_content' } }
.settings-header
@@ -76,22 +101,24 @@
%section.settings.as-note-limits.no-animate#js-note-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
- = _('Notes Rate Limits')
+ = _('Notes rate limit')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Configure limit for notes created per minute by web and API requests.')
+ = _('Set the per-user rate limit for notes created by web or API requests.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/rate_limit_on_notes_creation.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'note_limits'
%section.settings.as-import-export-limits.no-animate#js-import-export-limits-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
- = _('Import/Export Rate Limits')
+ = _('Import and export rate limits')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
%p
- = _('Configure limits for Project/Group Import/Export.')
+ = _('Set per-user rate limits for imports and exports of projects and groups.')
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/import_export_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'import_export_limits'
diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml
index 9711c335802..af4bfd28a01 100644
--- a/app/views/admin/application_settings/preferences.html.haml
+++ b/app/views/admin/application_settings/preferences.html.haml
@@ -82,3 +82,17 @@
= _('Configure the default first day of the week and time tracking units.')
.settings-content
= render 'localization'
+
+%section.settings.as-sidekiq-job-limits.no-animate#js-sidekiq-job-limits-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Sidekiq job size limits')
+ %button.btn.gl-button.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Limit the size of Sidekiq jobs stored in Redis.')
+ %span
+ = link_to _('Learn more.'), help_page_path('user/admin_area/settings/sidekiq_job_limits.md'), target: '_blank', rel: 'noopener noreferrer'
+
+ .settings-content
+ = render 'sidekiq_job_limits'
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 74eda21d5bd..a1990ad5750 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -33,6 +33,14 @@
%span.form-text.text-muted
= _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.')
+ = content_tag :div, class: 'form-group row' do
+ .col-sm-2.col-form-label.pt-0
+ = f.label :expire_access_tokens
+ .col-sm-10
+ = f.check_box :expire_access_tokens
+ %span.form-text.text-muted
+ = _('Access tokens expire after 2 hours. A refresh token may be used at any time to generate a new access token. Non-expiring access tokens are deprecated. Clear this setting to enable backward compatibility.')
+
.form-group.row
.col-sm-2.col-form-label.pt-0
= f.label :scopes
diff --git a/app/views/admin/applications/edit.html.haml b/app/views/admin/applications/edit.html.haml
index 4f737a14e12..42f7f6c3d66 100644
--- a/app/views/admin/applications/edit.html.haml
+++ b/app/views/admin/applications/edit.html.haml
@@ -2,6 +2,7 @@
- breadcrumb_title @application.name
- page_title _("Edit"), @application.name, _("Applications")
-%h3.page-title Edit application
+%h3.page-title
+ = _('Edit application')
- @url = admin_application_path(@application)
= render 'form', application: @application
diff --git a/app/views/admin/background_migrations/_migration.html.haml b/app/views/admin/background_migrations/_migration.html.haml
index ddb2eb27705..4a7c0083bc7 100644
--- a/app/views/admin/background_migrations/_migration.html.haml
+++ b/app/views/admin/background_migrations/_migration.html.haml
@@ -17,3 +17,7 @@
= button_to resume_admin_background_migration_path(migration),
class: 'gl-button btn btn-icon has-tooltip', title: _('Resume'), 'aria-label' => _('Resume') do
= sprite_icon('play', css_class: 'gl-button-icon gl-icon')
+ - elsif migration.failed?
+ = button_to retry_admin_background_migration_path(migration),
+ class: 'gl-button btn btn-icon has-tooltip', title: _('Retry'), 'aria-label' => _('Retry') do
+ = sprite_icon('retry', css_class: 'gl-button-icon gl-icon')
diff --git a/app/views/admin/deploy_keys/new.html.haml b/app/views/admin/deploy_keys/new.html.haml
index b0b12a01aed..fe2bc8530f7 100644
--- a/app/views/admin/deploy_keys/new.html.haml
+++ b/app/views/admin/deploy_keys/new.html.haml
@@ -7,4 +7,4 @@
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
= f.submit 'Create', class: 'btn gl-button btn-confirm', data: { qa_selector: "add_deploy_key_button" }
- = link_to 'Cancel', admin_deploy_keys_path, class: 'btn gl-button btn-default btn-cancel'
+ = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/admin/identities/edit.html.haml b/app/views/admin/identities/edit.html.haml
index fa09138c502..0fd1f2f547f 100644
--- a/app/views/admin/identities/edit.html.haml
+++ b/app/views/admin/identities/edit.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Users", admin_users_path
+- add_to_breadcrumbs _('Users'), admin_users_path
- add_to_breadcrumbs @user.name, admin_user_identities_path(@user)
-- breadcrumb_title "Edit Identity"
+- breadcrumb_title _('Edit Identity')
- page_title _("Edit"), @identity.provider, _("Identities"), @user.name, _("Users")
%h3.page-title
= _('Edit identity for %{user_name}') % { user_name: @user.name }
diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml
index d85ab476693..3b3042b5506 100644
--- a/app/views/admin/identities/index.html.haml
+++ b/app/views/admin/identities/index.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs "Users", admin_users_path
+- add_to_breadcrumbs _('Users'), admin_users_path
- breadcrumb_title @user.name
- page_title _("Identities"), @user.name, _("Users")
= render 'admin/users/head'
diff --git a/app/views/admin/identities/new.html.haml b/app/views/admin/identities/new.html.haml
index c28d22625b5..b4f37057c51 100644
--- a/app/views/admin/identities/new.html.haml
+++ b/app/views/admin/identities/new.html.haml
@@ -1,7 +1,7 @@
-- add_to_breadcrumbs "Users", admin_users_path
+- add_to_breadcrumbs _('Users'), admin_users_path
- add_to_breadcrumbs @user.name, admin_user_identities_path(@user)
-- breadcrumb_title "New Identity"
-- page_title _("New Identity")
+- breadcrumb_title _('New Identity')
+- page_title _('New Identity')
%h3.page-title= _('New identity')
%hr
= render 'form'
diff --git a/app/views/admin/impersonation_tokens/index.html.haml b/app/views/admin/impersonation_tokens/index.html.haml
index 1609687fc8d..26fbba83a32 100644
--- a/app/views/admin/impersonation_tokens/index.html.haml
+++ b/app/views/admin/impersonation_tokens/index.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs 'Users', admin_users_path
+- add_to_breadcrumbs _('Users'), admin_users_path
- breadcrumb_title @user.name
- page_title _('Impersonation Tokens'), @user.name, _('Users')
- type = _('impersonation token')
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index 79d77790b02..5ebfd296e2b 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -1,18 +1,17 @@
- page_title _('Projects')
- params[:visibility_level] ||= []
+- active_tab_classes = 'active gl-tab-nav-item-active gl-tab-nav-item-active-indigo'
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
- %ul.nav-links.nav.nav-tabs
- - opts = params[:visibility_level].present? ? {} : { page: admin_projects_path }
- = nav_link(opts) do
- = link_to _('All'), admin_projects_path
-
- = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s) }) do
- = link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s) }) do
- = link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
- = nav_link(html_options: { class: active_when(params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s) }) do
- = link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ %ul.nav.gl-tabs-nav.gl-overflow-x-auto.gl-display-flex.gl-flex-grow-1.gl-flex-shrink-1.gl-border-b-0.gl-flex-nowrap.gl-webkit-scrollbar-display-none
+ = nav_link(html_options: { class: "nav-item" } ) do
+ = link_to _('All'), admin_projects_path, class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level].empty?}"
+ = nav_link(html_options: { class: "nav-item" } ) do
+ = link_to _('Private'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PRIVATE), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::PRIVATE.to_s}"
+ = nav_link(html_options: { class: "nav-item" } ) do
+ = link_to _('Internal'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::INTERNAL), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::INTERNAL.to_s}"
+ = nav_link(html_options: { class: "nav-item" } ) do
+ = link_to _('Public'), admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC), class: "nav-link gl-tab-nav-item #{active_tab_classes if params[:visibility_level] == Gitlab::VisibilityLevel::PUBLIC.to_s}"
.nav-controls
.search-holder
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index 6f3c16f7abf..59523ed3a0c 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -4,15 +4,7 @@
- page_title "##{@runner.id} (#{@runner.short_sha})"
- add_to_breadcrumbs _('Runners'), admin_runners_path
-- if Feature.enabled?(:runner_detailed_view_vue_ui, current_user, default_enabled: :yaml)
- #js-runner-details{ data: {runner_id: @runner.id} }
-- else
- %h2.page-title
- = s_('Runners|Runner #%{runner_id}' % { runner_id: @runner.id })
- = render 'shared/runners/runner_type_badge', runner: @runner
- = render 'shared/runners/runner_type_alert', runner: @runner
- .gl-mb-6
- = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com?
+#js-runner-details{ data: {runner_id: @runner.id} }
.row
.col-md-6
@@ -35,7 +27,7 @@
%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-confirm btn-md gl-button'
+ = link_to _('Disable'), admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn gl-alert-action btn-confirm btn-md gl-button'
%table.table{ data: { testid: 'unassigned-projects' } }
%thead
@@ -60,7 +52,7 @@
.float-right
= form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f|
= f.hidden_field :runner_id, value: @runner.id
- = f.submit _('Enable'), class: 'gl-button btn btn-sm'
+ = f.submit _('Enable'), class: 'gl-button btn btn-sm', data: { confirm: (s_('Runners|You are about to change this instance runner to a project runner. This operation is not reversible. Are you sure you want to continue?') if @runner.instance_type?) }
= paginate_without_count @projects
.col-md-6
diff --git a/app/views/ci/group_variables/_variable_header.html.haml b/app/views/ci/group_variables/_variable_header.html.haml
index 75a432e7f7c..5ef9dc96691 100644
--- a/app/views/ci/group_variables/_variable_header.html.haml
+++ b/app/views/ci/group_variables/_variable_header.html.haml
@@ -1,7 +1,7 @@
%tr
%th
- = s_('Key')
+ = _('Key')
%th
- = s_('Environments')
+ = _('Environments')
%th
- = s_('Group')
+ = _('Group')
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index cdfc174ebf1..f289e6a3386 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -16,7 +16,7 @@
aws_tip_deploy_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'deploy-your-application-to-the-aws-elastic-container-service-ecs'),
aws_tip_commands_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'run-aws-commands-from-gitlab-cicd'),
aws_tip_learn_link: help_page_path('ci/cloud_deployment/index.md', anchor: 'aws'),
- contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'use-variables-or-in-other-variables'),
+ contains_variable_reference_link: help_page_path('ci/variables/index', anchor: 'use-variables-in-other-variables'),
protected_environment_variables_link: help_page_path('ci/variables/index', anchor: 'protect-a-cicd-variable'),
masked_environment_variables_link: help_page_path('ci/variables/index', anchor: 'mask-a-cicd-variable'),
} }
diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml
index 0c05ee4a6cd..a8f1d89f21e 100644
--- a/app/views/devise/mailer/unlock_instructions.html.haml
+++ b/app/views/devise/mailer/unlock_instructions.html.haml
@@ -1,6 +1,8 @@
#content
= email_default_heading(_("Hello, %{name}!") % { name: @resource.name })
%p
- = _("Your GitLab account has been locked due to an excessive amount of unsuccessful sign in attempts. Your account will automatically unlock in %{duration} or you may click the link below to unlock now.") % { duration: distance_of_time_in_words(Devise.unlock_in) }
+ = _("Your GitLab account has been locked due to an excessive number of unsuccessful sign in attempts. You can wait for your account to automatically unlock in %{duration} or you can click the link below to unlock now.") % { duration: distance_of_time_in_words(Devise.unlock_in) }
#cta
= link_to(_('Unlock account'), unlock_url(@resource, unlock_token: @token))
+ %p
+ = _('If you did not initiate these sign-in attempts, please reach out to your administrator or enable two-factor authentication (2FA) on your account.')
diff --git a/app/views/devise/mailer/unlock_instructions.text.erb b/app/views/devise/mailer/unlock_instructions.text.erb
index 9b1e2166cee..d58bb8facc3 100644
--- a/app/views/devise/mailer/unlock_instructions.text.erb
+++ b/app/views/devise/mailer/unlock_instructions.text.erb
@@ -1,5 +1,7 @@
<%= _('Hello, %{name}!') % { name: @resource.name } %>
-<%= _("Your GitLab account has been locked due to an excessive amount of unsuccessful sign in attempts. Your account will automatically unlock in %{duration} or you may click the link below to unlock now.") % { duration: distance_of_time_in_words(Devise.unlock_in) } %>
+<%= _("Your GitLab account has been locked due to an excessive number of unsuccessful sign in attempts. You can wait for your account to automatically unlock in %{duration} or you can click the link below to unlock now.") % { duration: distance_of_time_in_words(Devise.unlock_in) } %>
<%= unlock_url(@resource, unlock_token: @token) %>
+
+<%= _('If you did not initiate these sign-in attempts, please reach out to your administrator or enable two-factor authentication (2FA) on your account.') %>
diff --git a/app/views/devise/shared/_footer.html.haml b/app/views/devise/shared/_footer.html.haml
index ca1adb48543..5803107a8f7 100644
--- a/app/views/devise/shared/_footer.html.haml
+++ b/app/views/devise/shared/_footer.html.haml
@@ -4,5 +4,5 @@
- unless public_visibility_restricted?
= link_to _("Explore"), explore_root_path
= link_to _("Help"), help_path
- = link_to _("About GitLab"), "https://about.gitlab.com/"
+ = link_to _("About GitLab"), "https://#{ApplicationHelper.promo_host}"
= footer_message
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 8b54b735205..1752a43b032 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -7,10 +7,10 @@
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default omniauth-btn oauth-login #{qa_class_for_provider(provider)}" do
+ = button_to omniauth_authorize_path(:user, provider), id: "oauth-login-#{provider}", class: "btn gl-button btn-default omniauth-btn oauth-login #{qa_class_for_provider(provider)}", form: { class: 'gl-w-full' } do
- if has_icon
= provider_image_tag(provider)
- %span
+ %span.gl-button-text
= label_for_provider(provider)
- unless hide_remember_me
%fieldset.remember-me
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index a313ad7d23c..f9649875538 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -57,6 +57,7 @@
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 }
+ = render_if_exists 'devise/shared/phone_verification', form: f
%div
- if show_recaptcha_sign_up?
= recaptcha_tags nonce: content_security_policy_nonce
diff --git a/app/views/devise/shared/_tabs_normal.html.haml b/app/views/devise/shared/_tabs_normal.html.haml
index a2d5a8be625..01dd3748887 100644
--- a/app/views/devise/shared/_tabs_normal.html.haml
+++ b/app/views/devise/shared/_tabs_normal.html.haml
@@ -6,4 +6,4 @@
%a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= tab_title
- if render_signup_link && allow_signup?
%li.nav-item{ role: 'presentation' }
- %a.nav-link{ href: '#register-pane', data: { track_label: 'sign_in_register', track_property: '', track_event: 'click_button', track_value: '', toggle: 'tab', qa_selector: 'register_tab' }, role: 'tab' } Register
+ %a.nav-link{ href: '#register-pane', data: { track_label: 'sign_in_register', track_property: '', track_action: 'click_button', track_value: '', toggle: 'tab', qa_selector: 'register_tab' }, role: 'tab' } Register
diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml
index ce921060cab..e368ee1a75a 100644
--- a/app/views/errors/access_denied.html.haml
+++ b/app/views/errors/access_denied.html.haml
@@ -12,5 +12,5 @@
= s_('403|Please contact your GitLab administrator to get permission.')
.action-container.js-go-back{ hidden: true }
%button{ type: 'button', class: 'gl-button btn btn-success' }
- = s_('Go Back')
+ = _('Go Back')
= render "errors/footer"
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index 0c3eff85f16..ea191449fe3 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -30,8 +30,8 @@
= f.check_box :require_two_factor_authentication, class: 'form-check-input'
= f.label :require_two_factor_authentication, class: 'form-check-label' do
%strong
- = _("Require all users in this group to setup Two-factor authentication")
- = link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
+ = _("Require all users in this group to set up two-factor authentication")
+ = link_to sprite_icon('question-o'), help_page_path('security/two_factor_authentication', anchor: 'enforce-2fa-for-all-users-in-a-group')
.form-group.row
.offset-sm-2.col-sm-10
.form-check
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index b7c2b4d86b2..0352f366f5d 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -13,18 +13,18 @@
= @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'})
- .home-panel-metadata.d-flex.align-items-center.text-secondary
+ .home-panel-metadata.text-secondary
%span
= _("Group ID: %{group_id}") % { group_id: @group.id }
- if current_user
- %span.access-request-links.gl-ml-3
+ %span.gl-ml-3
= render 'shared/members/access_request_links', source: @group
.home-panel-buttons.col-md-12.col-lg-6
- if current_user
.gl-display-flex.gl-flex-wrap.gl-lg-justify-content-end.gl-mx-n2{ data: { testid: 'group-buttons' } }
- if current_user.admin?
- = link_to [:admin, @group], class: 'btn btn-default gl-button btn-icon gl-mt-3 gl-mr-2', title: s_('View group in admin area'),
+ = link_to [:admin, @group], class: 'btn btn-default gl-button btn-icon gl-mt-3 gl-mr-2', title: _('View group in admin area'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin')
- if @notification_setting
diff --git a/app/views/groups/_new_group_fields.html.haml b/app/views/groups/_new_group_fields.html.haml
index 49c8c2700ce..8ee7c91a938 100644
--- a/app/views/groups/_new_group_fields.html.haml
+++ b/app/views/groups/_new_group_fields.html.haml
@@ -13,7 +13,10 @@
- if Gitlab.config.mattermost.enabled
.row
= render 'create_chat_team', f: f
-.row
+
+= render 'personalize', f: f
+
+.row.js-invite-members-section
.col-sm-4
= render_if_exists 'shared/groups/invite_members'
diff --git a/app/views/groups/_personalize.html.haml b/app/views/groups/_personalize.html.haml
new file mode 100644
index 00000000000..5ecb0017cd8
--- /dev/null
+++ b/app/views/groups/_personalize.html.haml
@@ -0,0 +1,27 @@
+.row
+ .form-group.col-sm-12.gl-mb-0
+ %label.label-bold
+ = _('Now, personalize your GitLab experience')
+ %p
+ = _("We'll use this to help surface the right features and information to you.")
+
+.row
+ .form-group.col-sm-4
+ = label :user, :role, _('Role')
+ = select :user, :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { selected: @current_user.role }, class: 'form-control'
+
+.row
+ .form-group.col-sm-4
+ = f.label :setup_for_company, _('Who will be using this group?')
+ .gl-display-flex.gl-flex-direction-column.gl-lg-flex-direction-row
+ .gl-flex-grow-1.gl-display-flex.gl-align-items-center
+ = f.radio_button :setup_for_company, true, checked: true
+ = f.label :setup_for_company, _('My company or team'), class: 'gl-font-weight-normal gl-mb-0 gl-ml-2', value: 'true'
+ .gl-flex-grow-1.gl-display-flex.gl-align-items-center
+ = f.radio_button :setup_for_company, false
+ = f.label :setup_for_company, _('Just me'), class: 'gl-font-weight-normal gl-mb-0 gl-ml-2', value: 'false'
+
+.row
+ .form-group.col-sm-4
+ = f.label :jobs_to_be_done, _("What will you use this group for?")
+ = f.select :jobs_to_be_done, ::NamespaceSetting.jobs_to_be_dones.keys.map { |job_to_be_done| [localized_jobs_to_be_done_choices[job_to_be_done], job_to_be_done] }, { include_blank: true }, class: 'form-control'
diff --git a/app/views/groups/dependency_proxies/_url.html.haml b/app/views/groups/dependency_proxies/_url.html.haml
index a8034c50ed8..9a76da63a72 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_dependency_proxy_image_prefix(@group)
+- proxy_url = @group.dependency_proxy_image_prefix
%h5.prepend-top-20= _('Dependency proxy image prefix')
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index fdd6962eb21..1f746484b7d 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -5,29 +5,34 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
-.top-area
- = render 'shared/issuable/nav', type: :issues
- .nav-controls
- = render 'shared/issuable/feed_buttons'
+- if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml)
+ .js-issues-list{ data: group_issues_list_data(@group, current_user, @issues) }
+ - if @can_bulk_update
+ = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
+- else
+ .top-area
+ = render 'shared/issuable/nav', type: :issues
+ .nav-controls
+ = render 'shared/issuable/feed_buttons'
- - if @can_bulk_update
- = render_if_exists 'shared/issuable/bulk_update_button', type: :issues
+ - if @can_bulk_update
+ = render_if_exists 'shared/issuable/bulk_update_button', type: :issues
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues, with_feature_enabled: 'issues', with_shared: false, include_projects_in_subgroups: true
-= render 'shared/issuable/search_bar', type: :issues
+ = render 'shared/issuable/search_bar', type: :issues
-- if @can_bulk_update
- = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
+ - if @can_bulk_update
+ = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
-- if Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any?
- - if use_startup_call?
- - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
- .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
- 'can-bulk-edit': @can_bulk_update.to_json,
- 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
- 'sort-key': @sort,
- type: 'issues',
- 'scoped-labels-available': scoped_labels_available?(@group).to_json } }
-- else
- = render 'shared/issues', project_select_button: true
+ - if Feature.enabled?(:vue_issuables_list, @group) && @issues.to_a.any?
+ - if use_startup_call?
+ - add_page_startup_api_call(api_v4_groups_issues_path(id: @group.id, params: startup_call_params))
+ .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
+ 'can-bulk-edit': @can_bulk_update.to_json,
+ 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') },
+ 'sort-key': @sort,
+ type: 'issues',
+ 'scoped-labels-available': scoped_labels_available?(@group).to_json } }
+ - else
+ = render 'shared/issues', project_select_button: true
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 11927142ea6..0f11ca5fb8f 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -15,7 +15,7 @@
#import-group-pane.tab-pane
- if import_sources_enabled?
- - if Feature.enabled?(:bulk_import)
+ - if Feature.enabled?(:bulk_import, default_enabled: :yaml)
= 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'
diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml
index 13da2292985..66ffef98553 100644
--- a/app/views/groups/runners/_runner.html.haml
+++ b/app/views/groups/runners/_runner.html.haml
@@ -1,6 +1,3 @@
--# Note: This file should stay aligned with:
--# `app/views/admin/runners/_runner.html.haml`
-
.gl-responsive-table-row{ id: dom_id(runner) }
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Type')
diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml
index 4e7bc99b1f0..f904b34d29e 100644
--- a/app/views/groups/runners/index.html.haml
+++ b/app/views/groups/runners/index.html.haml
@@ -3,4 +3,4 @@
%h2.page-title
= s_('Runners|Group Runners')
-#js-group-runners{ data: { registration_token: @group.runners_token, group_id: @group.id } }
+#js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count } ) }
diff --git a/app/views/groups/settings/_membership.html.haml b/app/views/groups/settings/_membership.html.haml
new file mode 100644
index 00000000000..b05a294e864
--- /dev/null
+++ b/app/views/groups/settings/_membership.html.haml
@@ -0,0 +1,6 @@
+%h5= _('Membership')
+
+.form-group
+ = render 'shared/allow_request_access', form: f
+
+= render_if_exists 'groups/member_lock_setting', f: f, group: @group
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 683e70248b6..8f428909e60 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -4,9 +4,6 @@
%fieldset
%h5= _('Permissions')
- .form-group
- = render 'shared/allow_request_access', form: f
-
- if @group.root?
.form-group.gl-mb-3
= f.gitlab_ui_checkbox_component :prevent_sharing_groups_outside_hierarchy,
@@ -43,5 +40,5 @@
= render_if_exists 'groups/settings/prevent_forking', f: f, group: @group
= render 'groups/settings/two_factor_auth', f: f, group: @group
= render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group
- = render_if_exists 'groups/member_lock_setting', f: f, group: @group
+ = render 'groups/settings/membership', f: f, group: @group
= f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' }
diff --git a/app/views/groups/settings/_two_factor_auth.html.haml b/app/views/groups/settings/_two_factor_auth.html.haml
index 9e5eeee2e2a..8204cafcb44 100644
--- a/app/views/groups/settings/_two_factor_auth.html.haml
+++ b/app/views/groups/settings/_two_factor_auth.html.haml
@@ -1,5 +1,5 @@
- return unless group.parent_allows_two_factor_authentication?
-- docs_link_url = help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
+- docs_link_url = help_page_path('security/two_factor_authentication', anchor: 'enforce-2fa-for-all-users-in-a-group')
- docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_link_url }
%h5= _('Two-factor authentication')
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 018dd4c424d..331cb31c626 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -9,7 +9,7 @@
- if can?(current_user, :update_max_artifacts_size, @group)
%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _("General pipelines")
%button.btn.gl-button.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
@@ -26,7 +26,7 @@
%section.settings#runners-settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Runners')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: "button" }
= expanded ? _('Collapse') : _('Expand')
@@ -38,7 +38,7 @@
%section.settings#auto-devops-settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Auto DevOps')
%button.btn.gl-button.btn-default.js-settings-toggle{ type: "button" }
= expanded ? _('Collapse') : _('Expand')
diff --git a/app/views/groups/settings/repository/_initial_branch_name.html.haml b/app/views/groups/settings/repository/_initial_branch_name.html.haml
index 5299c38576d..15a3bacf12d 100644
--- a/app/views/groups/settings/repository/_initial_branch_name.html.haml
+++ b/app/views/groups/settings/repository/_initial_branch_name.html.haml
@@ -1,6 +1,6 @@
%section.settings.as-default-branch-name.no-animate#js-default-branch-name{ class: ('expanded' if expanded_by_default?) }
.settings-header
- %h4
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
= _('Default initial branch name')
%button.gl-button.js-settings-toggle{ type: 'button' }
= expanded_by_default? ? _('Collapse') : _('Expand')
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index 76850f0a884..2e74d983397 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -12,9 +12,11 @@
= content_for :group_invite_members_banner do
.container-fluid.container-limited{ class: "gl-pb-2! gl-pt-6! #{@content_class}" }
.js-group-invite-members-banner{ data: { svg_path: image_path('illustrations/merge_requests.svg'),
- is_dismissed_key: "invite_#{@group.id}_#{current_user.id}",
track_label: 'invite_members_banner',
- invite_members_path: group_group_members_path(@group) } }
+ invite_members_path: group_group_members_path(@group),
+ callouts_path: group_callouts_path,
+ callouts_feature_id: UserCalloutsHelper::INVITE_MEMBERS_BANNER,
+ group_id: @group.id } }
= render 'groups/invite_members_modal', group: @group
= content_for :meta_tags do
diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml
index 88c531535b4..411a81cb976 100644
--- a/app/views/help/instance_configuration.html.haml
+++ b/app/views/help/instance_configuration.html.haml
@@ -7,7 +7,7 @@
= render 'help/instance_configuration/ssh_info'
= render 'help/instance_configuration/gitlab_pages'
- = render 'help/instance_configuration/gitlab_ci'
+ = render 'help/instance_configuration/size_limits'
= render 'help/instance_configuration/package_registry'
= render 'help/instance_configuration/rate_limits'
%p
diff --git a/app/views/help/instance_configuration/_gitlab_ci.html.haml b/app/views/help/instance_configuration/_gitlab_ci.html.haml
deleted file mode 100644
index 53fa3f89873..00000000000
--- a/app/views/help/instance_configuration/_gitlab_ci.html.haml
+++ /dev/null
@@ -1,24 +0,0 @@
-- content_for :table_content do
- %li= link_to _('GitLab CI'), '#gitlab-ci'
-
-- content_for :settings_content do
- %h2#gitlab-ci
- = _('GitLab CI')
-
- %p
- = _('Below are the current settings regarding')
- = succeed('.') { link_to(_('GitLab CI'), 'https://about.gitlab.com/gitlab-ci', target: '_blank') }
-
- .table-responsive
- %table
- %thead
- %tr
- %th= _('Setting')
- %th= instance_configuration_host(@instance_configuration.settings[:host])
- %th= _('Default')
- %tbody
- %tr
- - artifacts_size = @instance_configuration.settings[:gitlab_ci][:artifacts_max_size]
- %td= _('Artifacts maximum size')
- %td= instance_configuration_human_size_cell(artifacts_size[:value])
- %td= instance_configuration_human_size_cell(artifacts_size[:default])
diff --git a/app/views/help/instance_configuration/_gitlab_pages.html.haml b/app/views/help/instance_configuration/_gitlab_pages.html.haml
index 55f043214f6..51835c202d6 100644
--- a/app/views/help/instance_configuration/_gitlab_pages.html.haml
+++ b/app/views/help/instance_configuration/_gitlab_pages.html.haml
@@ -28,8 +28,3 @@
%td= _('Port')
%td
%code= instance_configuration_cell_html(gitlab_pages[:port])
- %br
-
- %p
- - link_to_gitlab_ci = link_to(_('GitLab CI'), '#gitlab-ci')
- = _("The maximum size of your Pages site is regulated by the artifacts maximum size which is part of %{link_to_gitlab_ci}.").html_safe % { link_to_gitlab_ci: link_to_gitlab_ci }
diff --git a/app/views/help/instance_configuration/_size_limits.html.haml b/app/views/help/instance_configuration/_size_limits.html.haml
new file mode 100644
index 00000000000..b592eeed020
--- /dev/null
+++ b/app/views/help/instance_configuration/_size_limits.html.haml
@@ -0,0 +1,40 @@
+- size_limits = @instance_configuration.settings[:size_limits]
+- content_for :table_content do
+ - if size_limits.present?
+ %li= link_to _('Size Limits'), '#size-limits'
+
+- content_for :settings_content do
+ - if size_limits.present?
+ %h2#size-limits
+ = _('Size Limits')
+
+ %p
+ = _('There are several size limits in place.')
+ .table-responsive
+ %table
+ %thead
+ %tr
+ %th= _('Setting')
+ %th= instance_configuration_host(@instance_configuration.settings[:host])
+ %tbody
+ %tr
+ %td= _('Maximum attachment size')
+ %td= instance_configuration_human_size_cell(size_limits[:max_attachment_size])
+ %tr
+ %td= _('Maximum push size')
+ %td= instance_configuration_human_size_cell(size_limits[:receive_max_input_size])
+ %tr
+ %td= _('Maximum import size')
+ %td= instance_configuration_human_size_cell(size_limits[:max_import_size])
+ %tr
+ %td= _('Maximum diff patch size')
+ %td= instance_configuration_human_size_cell(size_limits[:diff_max_patch_bytes])
+ %tr
+ %td= _('Maximum job artifact size')
+ %td= instance_configuration_human_size_cell(size_limits[:max_artifacts_size])
+ %tr
+ %td= _('Maximum page size')
+ %td= instance_configuration_human_size_cell(size_limits[:max_pages_size])
+ %tr
+ %td= _('Maximum snippet size')
+ %td= instance_configuration_human_size_cell(size_limits[:snippet_size_limit])
diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder
index e2ab360a3e4..d14eff22bb6 100644
--- a/app/views/issues/_issue.atom.builder
+++ b/app/views/issues/_issue.atom.builder
@@ -3,42 +3,7 @@
xml.entry do
xml.id project_issue_url(issue.project, issue)
xml.link href: project_issue_url(issue.project, issue)
- xml.title truncate(issue.title, length: 80)
- xml.updated issue.updated_at.xmlschema
- xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(issue.author))
-
- xml.author do
- xml.name issue.author_name
- xml.email issue.author_public_email
- end
-
- xml.summary issue.title
- xml.description issue.description if issue.description
- xml.content issue.description if issue.description
- xml.milestone issue.milestone.title if issue.milestone
+ # using the shovel operator (xml <<) would make us lose indentation, so we do this (https://github.com/rails/rails/issues/7036)
+ render(partial: 'shared/issuable/issuable', object: issue, locals: { builder: xml })
xml.due_date issue.due_date if issue.due_date
-
- unless issue.labels.empty?
- xml.labels do
- issue.labels.each do |label|
- xml.label label.name
- end
- end
- end
-
- if issue.assignees.any?
- xml.assignees do
- issue.assignees.each do |assignee|
- xml.assignee do
- xml.name assignee.name
- xml.email assignee.public_email
- end
- end
- end
-
- xml.assignee do
- xml.name issue.assignees.first.name
- xml.email issue.assignees.first.public_email
- end
- end
end
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index ba2d6aa79eb..ec2904245d3 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -14,7 +14,7 @@
= render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/service_ping_consent"
- = render_account_recovery_regular_check
+ = render_two_factor_auth_recovery_settings_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"
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index 5ce275d4a43..2d186dfbd91 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -1,4 +1,4 @@
-.search.search-form{ data: { track_label: "navbar_search", track_event: "activate_form_input", track_value: "" } }
+.search.search-form{ data: { track_label: "navbar_search", track_action: "activate_form_input", track_value: "" } }
= form_tag search_path, method: :get, class: 'form-inline form-control' do |_f|
.search-input-container
.search-input-wrap
diff --git a/app/views/layouts/_snowplow.html.haml b/app/views/layouts/_snowplow.html.haml
index 9c0384e5faa..fc3b12acc46 100644
--- a/app/views/layouts/_snowplow.html.haml
+++ b/app/views/layouts/_snowplow.html.haml
@@ -7,7 +7,8 @@
};p[i].q=p[i].q||[];n=l.createElement(o);g=l.getElementsByTagName(o)[0];n.async=1;
n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","#{asset_url('snowplow/sp.js')}","snowplow"));
- window.snowplowOptions = #{Gitlab::Tracking.snowplow_options(@group).to_json}
+ window.snowplowOptions = #{Gitlab::Tracking.options(@group).to_json}
gl = window.gl || {};
gl.snowplowStandardContext = #{Gitlab::Tracking::StandardContext.new.to_context.to_json.to_json}
+ gl.snowplowPseudonymizedPageUrl = #{masked_page_url.to_json};
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 2f6287bdfb3..3e7155b2c0e 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -15,7 +15,7 @@
%span.logo-text.d-none.d-lg-block.gl-ml-3
= logo_text
- if Gitlab.com_and_canary?
- = link_to 'https://next.gitlab.com', class: 'canary-badge bg-transparent', target: :_blank, rel: :_noopener do
+ = link_to 'https://next.gitlab.com', class: 'canary-badge bg-transparent', data: { qa_selector: 'canary_badge_link' }, target: :_blank, rel: :_noopener do
%span.gl-badge.gl-bg-green-500.gl-text-white.gl-rounded-pill.gl-font-weight-bold.gl-py-1
= _('Next')
@@ -29,7 +29,15 @@
- if top_nav_show_search
- search_menu_item = top_nav_search_menu_item_attrs
%li.nav-item.d-none.d-lg-block.m-auto
- = render 'layouts/search' unless current_controller?(:search)
+ - unless current_controller?(:search)
+ - if Feature.enabled?(:new_header_search)
+ #js-header-search.header-search{ data: { 'search-context' => search_context.to_json,
+ 'search-path' => search_path,
+ 'issues-path' => issues_dashboard_path,
+ 'mr-path' => merge_requests_dashboard_path } }
+ %input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' }
+ - else
+ = render 'layouts/search'
%li.nav-item{ class: 'd-none d-sm-inline-block d-lg-none' }
= link_to search_menu_item.fetch(:href), title: search_menu_item.fetch(:title), aria: { label: search_menu_item.fetch(:title) }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon(search_menu_item.fetch(:icon))
@@ -38,7 +46,7 @@
= link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') },
data: { qa_selector: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom',
track_label: 'main_navigation',
- track_event: 'click_issues_link',
+ track_action: 'click_issues_link',
track_property: 'navigation',
container: 'body' } do
= sprite_icon('issues')
@@ -52,7 +60,7 @@
toggle: "dropdown",
placement: 'bottom',
track_label: 'main_navigation',
- track_event: 'click_merge_link',
+ track_action: 'click_merge_link',
track_property: 'navigation',
container: 'body' } do
= sprite_icon('git-merge')
@@ -78,7 +86,7 @@
= link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos',
data: { qa_selector: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom',
track_label: 'main_navigation',
- track_event: 'click_to_do_link',
+ track_action: 'click_to_do_link',
track_property: 'navigation',
container: 'body' } do
= sprite_icon('todo-done')
@@ -94,7 +102,7 @@
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/help_dropdown'
- if header_link?(:user_dropdown)
- %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) }
+ %li.nav-item.header-user.js-nav-user-dropdown.dropdown{ data: { track_label: "profile_dropdown", track_action: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) }
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar", alt: current_user.name
= render_if_exists 'layouts/header/user_notification_dot', project: project, namespace: group
diff --git a/app/views/layouts/header/_new_dropdown.html.haml b/app/views/layouts/header/_new_dropdown.html.haml
index 0be87ad963c..a0b271fdafa 100644
--- a/app/views/layouts/header/_new_dropdown.html.haml
+++ b/app/views/layouts/header/_new_dropdown.html.haml
@@ -6,7 +6,7 @@
- return if menu_sections.empty?
-%li.header-new.dropdown{ class: top_class, data: { track_label: "new_dropdown", track_event: "click_dropdown" } }
+%li.header-new.dropdown{ class: top_class, data: { track_label: "new_dropdown", track_action: "click_dropdown" } }
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", id: "js-onboarding-new-project-link", title: title, ref: 'tooltip', aria: { label: title }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static', qa_selector: 'new_menu_toggle' } do
= sprite_icon('plus-square')
= sprite_icon('chevron-down', css_class: 'caret-down')
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 980730bc3be..c2b50bc0e52 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,3 +1 @@
--# We're migration the group sidebar to a logical model based structure. If you need to update
--# any of the existing menus, you can find them in app/views/layouts/nav/sidebar/_group_menus.html.haml.
= render partial: 'shared/nav/sidebar', object: Sidebars::Groups::Panel.new(group_sidebar_context(@group, current_user))
diff --git a/app/views/layouts/nav/sidebar/_group_menus.html.haml b/app/views/layouts/nav/sidebar/_group_menus.html.haml
deleted file mode 100644
index 25b6c264d92..00000000000
--- a/app/views/layouts/nav/sidebar/_group_menus.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-= render_if_exists "groups/ee/administration_nav"
-
-= render 'shared/sidebar_toggle_button'
diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml
index 843a820bd1b..5489a2ac6a1 100644
--- a/app/views/notify/member_invited_email.html.haml
+++ b/app/views/notify/member_invited_email.html.haml
@@ -5,7 +5,6 @@
br_tag: '<br/>'.html_safe,
role: member.human_access.downcase }
- join_text = s_('InviteEmail|Join now')
-- join_url = invite_url(@token, invite_type: Emails::Members::INITIAL_INVITE, experiment_name: 'invite_email_preview_text')
- inviter_name = member.created_by.name if member.created_by
- experiment(:invite_email_preview_text, actor: member) do |experiment_instance|
@@ -17,7 +16,7 @@
= s_('InviteEmail|Join your team on GitLab! %{inviter} invited you to %{project_or_group_name}') % { inviter: inviter_name, project_or_group_name: placeholders[:project_or_group_name] }
- else
= s_('InviteEmail|Join your team on GitLab! You are invited to %{project_or_group_name}') % { project_or_group_name: placeholders[:project_or_group_name] }
- = gmail_goto_action(join_text, join_url)
+ = gmail_goto_action(join_text, invited_join_url(@token, member))
%tr
%td.text-content{ colspan: 2 }
@@ -28,7 +27,7 @@
- else
= html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders
%p.invite-actions
- = link_to join_text, join_url, class: 'invite-btn-join'
+ = link_to join_text, invited_join_url(@token, member), class: 'invite-btn-join'
%tr.border-top
%td.text-content.mailer-align-left.half-width
%h4
diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml
index 6691d20c8f7..bc678c2c429 100644
--- a/app/views/profiles/_email_settings.html.haml
+++ b/app/views/profiles/_email_settings.html.haml
@@ -11,6 +11,6 @@
- commit_email_link_url = help_page_path('user/profile/index', anchor: 'change-the-email-displayed-on-your-commits', target: '_blank')
- commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url }
- commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe }
-= form.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)),
+= form.select :commit_email, options_for_select(commit_email_select_options(@user), selected: @user.commit_email),
{ help: commit_email_docs_link },
control_class: 'select2 input-lg', disabled: email_change_disabled
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index c14efa99555..35bdfbb1c29 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -38,21 +38,21 @@
= render partial: 'shared/email_with_badge', locals: { email: @primary_email, verified: current_user.confirmed? }
%span.float-right
%span.badge.badge-muted.badge-pill.gl-badge.badge-success= s_('Profiles|Primary email')
- - if @primary_email === current_user.commit_email
+ - if @primary_email === current_user.commit_email_or_default
%span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Commit email')
- if @primary_email === current_user.public_email
%span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Public email')
- - if @primary_email === current_user.notification_email
+ - if @primary_email === current_user.notification_email_or_default
%span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Default notification email')
- @emails.each do |email|
%li{ data: { qa_selector: 'email_row_content' } }
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }
%span.float-right
- - if email.email === current_user.commit_email
+ - if email.email === current_user.commit_email_or_default
%span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Commit email')
- if email.email === current_user.public_email
%span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Public email')
- - if email.email === current_user.notification_email
+ - if email.email === current_user.notification_email_or_default
%span.badge.badge-muted.badge-pill.gl-badge.badge-info= s_('Profiles|Notification email')
- unless email.confirmed?
- confirm_title = "#{email.confirmation_sent_at ? _('Resend confirmation email') : _('Send confirmation email')}"
diff --git a/app/views/profiles/keys/show.html.haml b/app/views/profiles/keys/show.html.haml
index 360de7a0c11..09c16b0c038 100644
--- a/app/views/profiles/keys/show.html.haml
+++ b/app/views/profiles/keys/show.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs "SSH Keys", profile_keys_path
+- add_to_breadcrumbs _('SSH Keys'), profile_keys_path
- breadcrumb_title @key.title
- page_title @key.title, _('SSH Keys')
- @content_class = "limit-container-width" unless fluid_layout
diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml
index f452a5b2eb5..f2121199412 100644
--- a/app/views/profiles/notifications/_email_settings.html.haml
+++ b/app/views/profiles/notifications/_email_settings.html.haml
@@ -1,7 +1,7 @@
- form = local_assigns.fetch(:form)
.form-group
= form.label :notification_email, class: "label-bold"
- = form.select :notification_email, @user.public_verified_emails, { include_blank: false }, class: "select2", disabled: local_assigns.fetch(:email_change_disabled, nil)
+ = form.select :notification_email, @user.public_verified_emails, { include_blank: _('Use primary email (%{email})') % { email: @user.email }, selected: @user.notification_email }, class: "select2", disabled: local_assigns.fetch(:email_change_disabled, nil)
.help-block
= local_assigns.fetch(:help_text, nil)
.form-group
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 6eba0309a4f..0bb4859dd1e 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -99,11 +99,12 @@
= f.text_field :pronouns, class: 'input-md gl-form-input', help: s_("Profiles|Enter your pronouns to let people know how to refer to you")
= f.text_field :pronunciation, class: 'input-md gl-form-input', help: s_("Profiles|Enter how your name is pronounced to help people address you correctly")
+ = render_if_exists 'profiles/extra_settings', form: f
= render_if_exists 'profiles/email_settings', form: f
= f.text_field :skype, class: 'input-md gl-form-input', placeholder: s_("Profiles|username")
= f.text_field :linkedin, class: 'input-md gl-form-input', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
= f.text_field :twitter, class: 'input-md gl-form-input', placeholder: s_("Profiles|@username")
- = f.text_field :website_url, class: 'input-lg gl-form-input', placeholder: s_("Profiles|website.com")
+ = f.text_field :website_url, class: 'input-lg gl-form-input', placeholder: s_("Profiles|https://website.com")
- if @user.read_only_attribute?(:location)
= f.text_field :location, class: 'gl-form-input', readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
- else
@@ -112,7 +113,7 @@
= f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md gl-form-input', help: s_("Profiles|Who you represent or work for")
= f.text_area :bio, class: 'gl-form-input', label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters")
%hr
- %h5= s_("Private profile")
+ %h5= _('Private profile')
.checkbox-icon-inline-wrapper
- private_profile_label = capture do
= s_("Profiles|Don't display activity-related personal information on your profiles")
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 987ec74e4ba..2c18921d874 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -1,7 +1,7 @@
.form-actions.gl-display-flex
= button_tag 'Commit changes', id: 'commit-changes', class: 'gl-button btn btn-confirm js-commit-button qa-commit-button'
- = link_to 'Cancel', cancel_path,
+ = link_to _('Cancel'), cancel_path,
class: 'gl-button btn btn-default gl-ml-3', data: {confirm: leave_edit_message}
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index 8909536a1ec..f2cee618849 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -15,13 +15,13 @@
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, options: { class: 'icon' })
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project
- .home-panel-metadata.d-flex.flex-wrap.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal
+ .home-panel-metadata.text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal
- if can?(current_user, :read_project, @project)
- button_class = "btn gl-button btn-sm btn-tertiary btn-default-tertiary home-panel-metadata"
- button_text = s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
= clipboard_button(title: s_('ProjectPage|Copy project ID'), text: @project.id, hide_button_icon: true, button_text: button_text, class: button_class, qa_selector: 'project_id_content', itemprop: 'identifier')
- if current_user
- %span.access-request-links.gl-ml-3
+ %span.gl-display-inline-block.gl-vertical-align-middle.gl-ml-3
= render 'shared/members/access_request_links', source: @project
.gl-mt-3.gl-pl-3.gl-w-full
@@ -31,7 +31,7 @@
.project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-start.gl-flex-wrap.gl-mt-5
- if current_user
- if current_user.admin?
- = link_to [:admin, @project], class: 'btn gl-button btn-icon gl-align-self-start gl-py-2! gl-mr-3', title: s_('View project in admin area'),
+ = link_to [:admin, @project], class: 'btn gl-button btn-icon gl-align-self-start gl-py-2! gl-mr-3', title: _('View project in admin area'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin')
.gl-display-flex.gl-align-items-start.gl-mr-3
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 2055f1c7f60..815a3cf6966 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -76,7 +76,7 @@
- if phabricator_import_enabled?
%div
- = 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
+ = link_to new_import_phabricator_path, class: 'gl-button btn-default btn import_phabricator', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "phabricator" } do
.gl-button-icon
= custom_icon('issues')
= _("Phabricator Tasks")
diff --git a/app/views/projects/_invite_members_empty_project.html.haml b/app/views/projects/_invite_members_empty_project.html.haml
index ee2215b0fbb..5bc53339bf0 100644
--- a/app/views/projects/_invite_members_empty_project.html.haml
+++ b/app/views/projects/_invite_members_empty_project.html.haml
@@ -1,6 +1,6 @@
%h4.gl-mt-0.gl-mb-3{ data: { testid: 'invite-member-section',
track_label: 'invite_members_empty_project',
- track_event: 'render' } }
+ track_action: 'render' } }
= s_('InviteMember|Invite your team')
%p= s_('InviteMember|Add members to this project and start collaborating with your team.')
.js-invite-members-trigger{ data: { variant: 'confirm',
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index 026c7a0d79d..fb7a7ef8985 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -8,23 +8,25 @@
.form-group.project-name.col-sm-12
= f.label :name, class: 'label-bold' do
%span= _("Project name")
- = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", autofocus: true, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
+ = f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", data: { track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
.form-group.project-path.col-sm-6
= f.label :namespace_id, class: 'label-bold' do
- %span= s_("Project URL")
+ %span= _('Project URL')
.input-group.gl-flex-nowrap
- if current_user.can_select_namespace?
- .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
- .input-group-text
- = root_url
- namespace_id = namespace_id_from(params)
- = f.select(:namespace_id,
- namespaces_options_with_developer_maintainer_access(selected: namespace_id,
- display_path: true,
- extra_group: namespace_id),
- {},
- { class: 'select2 js-select-namespace qa-project-namespace-select block-truncated', data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "", qa_selector: "select_namespace_dropdown" }})
-
+ - if Feature.enabled?(:paginatable_namespace_drop_down_for_project_creation, current_user, default_enabled: :yaml)
+ .js-vue-new-project-url-select{ data: { namespace_full_path: GroupFinder.new(current_user).execute(id: namespace_id)&.full_path, namespace_id: namespace_id, root_url: root_url, track_label: track_label } }
+ - else
+ .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
+ .input-group-text
+ = root_url
+ = f.select(:namespace_id,
+ namespaces_options_with_developer_maintainer_access(selected: namespace_id,
+ display_path: true,
+ extra_group: namespace_id),
+ {},
+ { class: 'select2 js-select-namespace qa-project-namespace-select block-truncated', data: { track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_path", track_value: "", qa_selector: "select_namespace_dropdown" }})
- else
.input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0
@@ -43,7 +45,7 @@
.form-group
= f.label :description, class: 'label-bold' do
= s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe }
- = f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" }
+ = f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_action: "activate_form_input", track_property: "project_description", track_value: "" }
= f.label :visibility_level, class: 'label-bold' do
= s_('ProjectsNew|Visibility Level')
@@ -54,12 +56,12 @@
.form-group.row.initialize-with-readme-setting
%div{ :class => "col-sm-12" }
.form-check
- = check_box_tag 'project[initialize_with_readme]', '1', true, class: 'form-check-input', data: { qa_selector: "initialize_with_readme_checkbox", track_label: "#{track_label}", track_event: "activate_form_input", track_property: "init_with_readme", track_value: "" }
+ = check_box_tag 'project[initialize_with_readme]', '1', true, class: 'form-check-input', data: { qa_selector: "initialize_with_readme_checkbox", track_label: "#{track_label}", track_action: "activate_form_input", track_property: "init_with_readme", track_value: "" }
= label_tag 'project[initialize_with_readme]', class: 'form-check-label' do
.option-title
%strong= s_('ProjectsNew|Initialize repository with a README')
.option-description
= s_('ProjectsNew|Allows you to immediately clone this project’s repository. Skip this if you plan to push up an existing repository.')
-= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_event: "click_button", track_property: "create_project", track_value: "" }
-= link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "cancel", track_value: "" }
+= f.submit _('Create project'), class: "btn gl-button btn-confirm", data: { track_label: "#{track_label}", track_action: "click_button", track_property: "create_project", track_value: "" }
+= link_to _('Cancel'), dashboard_projects_path, class: 'btn gl-button btn-default btn-cancel', data: { track_label: "#{track_label}", track_action: "click_button", track_property: "cancel", track_value: "" }
diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml
index 905dc2a49ec..3cc9fea56e2 100644
--- a/app/views/projects/blob/_new_dir.html.haml
+++ b/app/views/projects/blob/_new_dir.html.haml
@@ -16,6 +16,6 @@
.form-actions
= submit_tag _("Create directory"), class: 'btn gl-button btn-confirm'
- = link_to "Cancel", '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal"
+ = link_to _('Cancel'), '#', class: "btn gl-button btn-default btn-cancel", "data-dismiss" => "modal"
= render 'shared/projects/edit_information'
diff --git a/app/views/projects/blob/_remove.html.haml b/app/views/projects/blob/_remove.html.haml
index 298a36e28ec..1463fcf8052 100644
--- a/app/views/projects/blob/_remove.html.haml
+++ b/app/views/projects/blob/_remove.html.haml
@@ -13,4 +13,4 @@
.form-group.row
.offset-sm-2.col-sm-10
= button_tag 'Delete file', class: 'btn gl-button btn-danger btn-remove-file'
- = link_to "Cancel", '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
+ = link_to _('Cancel'), '#', class: "btn gl-button btn-cancel", "data-dismiss" => "modal"
diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml
index 1ba38808937..66e9badbafb 100644
--- a/app/views/projects/blob/show.html.haml
+++ b/app/views/projects/blob/show.html.haml
@@ -1,4 +1,4 @@
-- breadcrumb_title "Repository"
+- breadcrumb_title _('Repository')
- page_title @blob.path, @ref
- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.full_path, project_id: @project.path, id: @last_commit, limit: 1)
- content_for :prefetch_asset_tags do
diff --git a/app/views/projects/blob/viewers/_notebook.html.haml b/app/views/projects/blob/viewers/_notebook.html.haml
index eb4ca1b9816..b537a48e087 100644
--- a/app/views/projects/blob/viewers/_notebook.html.haml
+++ b/app/views/projects/blob/viewers/_notebook.html.haml
@@ -1 +1 @@
-.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_path } }
+.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_path, relative_raw_path: parent_dir_raw_path } }
diff --git a/app/views/projects/branches/new.html.haml b/app/views/projects/branches/new.html.haml
index 6de50d48721..27858932e5e 100644
--- a/app/views/projects/branches/new.html.haml
+++ b/app/views/projects/branches/new.html.haml
@@ -9,7 +9,7 @@
.gl-alert-body
= @error
%h3.page-title
- New Branch
+ = _('New Branch')
%hr
= form_tag namespace_project_branches_path, method: :post, id: "new-branch-form", class: "js-create-branch-form js-requires-input" do
@@ -30,5 +30,5 @@
.form-text.text-muted Existing branch name, tag, or commit SHA
.form-actions
= button_tag 'Create branch', class: 'gl-button btn btn-confirm'
- = link_to 'Cancel', project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel'
+ = link_to _('Cancel'), project_branches_path(@project), class: 'gl-button btn btn-default btn-cancel'
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 1a3813ba99f..437529c3608 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -7,10 +7,14 @@
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false)
- allow_retry = local_assigns.fetch(:allow_retry, false)
+-# This prevents initializing another Ci::Status object where 'status' is used
+- status = job.detailed_status(current_user)
%tr.build.commit{ class: ('retried' if retried) }
%td.status
- = render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title
+ -# Sending 'status' prevents calling the user relation inside the presenter, generating N+1,
+ -# see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68743
+ = render "ci/status/badge", status: status, title: job.status_title(status)
%td
- if can?(current_user, :read_build, job)
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index 3c9762e200a..f398ac6ede7 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -1,6 +1,4 @@
- page_title _("Value Stream Analytics")
- add_page_specific_style 'page_bundles/cycle_analytics'
-- svgs = { empty_state_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_data_svg_path: image_path("illustrations/analytics/cycle-analytics-empty-chart.svg"), no_access_svg_path: image_path("illustrations/analytics/no-access.svg") }
-- initial_data = { project_id: @project.id, group_path: @project.group&.path, request_path: project_cycle_analytics_path(@project), full_path: @project.full_path }.merge!(svgs)
-#js-cycle-analytics{ data: initial_data }
+#js-cycle-analytics{ data: cycle_analytics_initial_data(@project, @group) }
diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml
index 8edaacf7552..fe9658a440a 100644
--- a/app/views/projects/diffs/_stats.html.haml
+++ b/app/views/projects/diffs/_stats.html.haml
@@ -1,41 +1 @@
-- sum_added_lines = diff_files.sum(&:added_lines)
-- sum_removed_lines = diff_files.sum(&:removed_lines)
-.commit-stat-summary.dropdown
- Showing
- %button.diff-stats-summary-toggler.js-diff-stats-dropdown{ type: "button", data: { toggle: "dropdown", display: "static" } }<
- = pluralize(diff_files.size, "changed file")
- = 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')
- and
- %strong.cred= pluralize(sum_removed_lines, 'deletion')
- .diff-stats-additions-deletions-collapsed.float-right.d-none{ "aria-hidden": "true", "aria-describedby": "diff-stats" }
- %strong.cgreen<
- +#{sum_added_lines}
- %strong.cred<
- \-#{sum_removed_lines}
- .dropdown-menu.diff-file-changes
- = dropdown_filter("Search files")
- .dropdown-content
- %ul
- - diff_files.each do |diff_file|
- %li
- %a.diff-changed-file{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path }
- = sprite_icon(diff_file_changed_icon(diff_file), css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon gl-mr-3")
- %span.diff-changed-file-content.gl-mr-3
- - if diff_file.file_path
- %strong.diff-changed-file-name
- = diff_file.file_path
- - else
- %strong.diff-changed-blank-file-name
- = s_('Diffs|No file name available')
- %span.diff-changed-file-path.gl-mt-2= diff_file_path_text(diff_file)
- %span.diff-changed-stats
- %span.cgreen<
- +#{diff_file.added_lines}
- %span.cred<
- \-#{diff_file.removed_lines}
- %li.dropdown-menu-empty-item.hidden
- %a
- = _("No files found.")
+.js-diff-stats-dropdown{ data: { changed: diff_files.size, added: diff_files.sum(&:added_lines), deleted: diff_files.sum(&:removed_lines), files: diff_files_data(diff_files) } }
diff --git a/app/views/projects/error_tracking/details.html.haml b/app/views/projects/error_tracking/details.html.haml
index 4a14e34cbf1..eb26a299a66 100644
--- a/app/views/projects/error_tracking/details.html.haml
+++ b/app/views/projects/error_tracking/details.html.haml
@@ -1,5 +1,5 @@
- page_title _('Error Details')
-- add_to_breadcrumbs 'Errors', project_error_tracking_index_path(@project)
+- add_to_breadcrumbs _('Errors'), project_error_tracking_index_path(@project)
- add_page_specific_style 'page_bundles/error_tracking_details'
#js-error_details{ data: error_details_data(@project, @issue_id) }
diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml
index 226cd7d89b6..e8ea4ad90dc 100644
--- a/app/views/projects/hooks/edit.html.haml
+++ b/app/views/projects/hooks/edit.html.haml
@@ -10,7 +10,7 @@
= form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f|
= render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook }
- = f.submit 'Save changes', class: 'btn gl-button btn-confirm gl-mr-3'
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3'
= render 'shared/web_hooks/test_button', hook: @hook
= link_to _('Delete'), project_hook_path(@project, @hook), method: :delete, class: 'btn gl-button btn-danger float-right', data: { confirm: _('Are you sure?') }
diff --git a/app/views/projects/imports/new.html.haml b/app/views/projects/imports/new.html.haml
index e2d8791b5d2..77c715aa376 100644
--- a/app/views/projects/imports/new.html.haml
+++ b/app/views/projects/imports/new.html.haml
@@ -1,6 +1,6 @@
- page_title _("Import repository")
%h3.page-title
- Import repository
+ = _('Import repository')
%hr
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index ee3aaee6dbb..2de2c2cba6c 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -12,9 +12,7 @@
- if issue.confidential?
%span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue)
- - if Feature.enabled?(:ban_user_feature_flag) && issue.hidden?
- %span.has-tooltip{ title: _('This issue is hidden because its author has been banned') }
- = hidden_issue_icon(issue)
+ = hidden_issue_icon(issue)
= link_to issue.title, issue_path(issue)
= render_if_exists 'projects/issues/subepic_flag', issue: issue
- if issue.tasks?
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index 1289f7aa0c4..0d69f6f69aa 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -3,7 +3,7 @@
- show_export_button = local_assigns.fetch(:show_export_button, true)
- issuable_type = 'issues'
- can_edit = can?(current_user, :admin_project, @project)
-- notification_email = @current_user.present? ? @current_user.notification_email : nil
+- notification_email = @current_user.present? ? @current_user.notification_email_or_default : nil
.nav-controls.issues-nav-controls
- if show_feed_buttons
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index ecf10cd4821..53c2052bfab 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -13,8 +13,8 @@
issues_path: project_issues_path(@project),
project_path: @project.full_path } }
-- if Feature.enabled?(:vue_issues_list, @project)
- .js-issues-list{ data: issues_list_data(@project, current_user, finder) }
+- if Feature.enabled?(:vue_issues_list, @project&.group, default_enabled: :yaml)
+ .js-issues-list{ data: project_issues_list_data(@project, current_user, finder) }
- if @can_bulk_update
= render 'shared/issuable/bulk_update_sidebar', type: :issues
- elsif project_issues(@project).exists?
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index 1da3881c104..c58c6ab8287 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -1,8 +1,9 @@
- page_title _("Jobs")
- add_page_specific_style 'page_bundles/ci_status'
+- admin = local_assigns.fetch(:admin, false)
- if Feature.enabled?(:jobs_table_vue, @project, default_enabled: :yaml)
- #js-jobs-table{ data: { full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } }
+ #js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_counts: job_counts.to_json, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } }
- else
.top-area
- build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) }
diff --git a/app/views/projects/labels/edit.html.haml b/app/views/projects/labels/edit.html.haml
index 343900359b4..8023fb93c64 100644
--- a/app/views/projects/labels/edit.html.haml
+++ b/app/views/projects/labels/edit.html.haml
@@ -3,6 +3,6 @@
- page_title _("Edit"), @label.name, _("Labels")
%h3.page-title
- Edit Label
+ = _('Edit Label')
%hr
= render 'shared/labels/form', url: project_label_path(@project, @label), back_path: project_labels_path(@project)
diff --git a/app/views/projects/labels/new.html.haml b/app/views/projects/labels/new.html.haml
index 38bd6102437..1ae87bf93d1 100644
--- a/app/views/projects/labels/new.html.haml
+++ b/app/views/projects/labels/new.html.haml
@@ -3,6 +3,6 @@
- page_title _("New Label")
%h3.page-title
- New Label
+ = _('New Label')
%hr
= render 'shared/labels/form', url: project_labels_path(@project), back_path: project_labels_path(@project)
diff --git a/app/views/projects/mattermosts/_team_selection.html.haml b/app/views/projects/mattermosts/_team_selection.html.haml
index 4109fdfc13b..4832880eefc 100644
--- a/app/views/projects/mattermosts/_team_selection.html.haml
+++ b/app/views/projects/mattermosts/_team_selection.html.haml
@@ -42,5 +42,5 @@
%hr
.clearfix
.float-right
- = link_to 'Cancel', edit_project_service_path(@project, @integration), class: 'gl-button btn btn-lg'
+ = link_to _('Cancel'), edit_project_service_path(@project, @integration), class: 'gl-button btn btn-lg'
= f.submit 'Install', class: 'gl-button btn btn-success btn-lg'
diff --git a/app/views/projects/merge_requests/_merge_request.atom.builder b/app/views/projects/merge_requests/_merge_request.atom.builder
new file mode 100644
index 00000000000..e27cf93bb97
--- /dev/null
+++ b/app/views/projects/merge_requests/_merge_request.atom.builder
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+xml.entry do
+ xml.id project_merge_request_url(merge_request.project, merge_request)
+ xml.link href: project_merge_request_url(merge_request.project, merge_request)
+ # using the shovel operator (xml <<) would make us lose indentation, so we do this (https://github.com/rails/rails/issues/7036)
+ render(partial: 'shared/issuable/issuable', object: merge_request, locals: { builder: xml })
+end
diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml
index e2123e36e67..0abbf953fc7 100644
--- a/app/views/projects/merge_requests/_merge_requests.html.haml
+++ b/app/views/projects/merge_requests/_merge_requests.html.haml
@@ -2,7 +2,7 @@
- if @merge_requests.present?
= render @merge_requests
- else
- = render 'shared/empty_states/merge_requests'
+ = render 'shared/empty_states/merge_requests', button_path: new_merge_request_path
- if @merge_requests.present?
= paginate_collection @merge_requests, total_pages: @total_pages
diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml
index 511e53b192f..b34cf23634c 100644
--- a/app/views/projects/merge_requests/_nav_btns.html.haml
+++ b/app/views/projects/merge_requests/_nav_btns.html.haml
@@ -1,6 +1,7 @@
- issuable_type = 'merge-requests'
-- notification_email = @current_user.present? ? @current_user.notification_email : nil
+- notification_email = @current_user.present? ? @current_user.notification_email_or_default : nil
+= render 'shared/issuable/feed_buttons', show_calendar_button: false
.js-csv-import-export-buttons{ data: { show_export_button: "true", issuable_type: issuable_type, issuable_count: issuables_count_for_state(issuable_type.to_sym, params[:state]), email: notification_email, export_csv_path: export_csv_project_merge_requests_path(@project, request.query_parameters), container_class: 'gl-mr-3' } }
- if @can_bulk_update
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 5f2cb1cfcc4..47a0d05fc65 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -18,5 +18,7 @@
window.gl.mrWidgetData.approvals_help_path = '#{help_page_path("user/project/merge_requests/merge_request_approvals")}';
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
window.gl.mrWidgetData.codequality_help_path = '#{help_page_path("user/project/merge_requests/code_quality", anchor: "code-quality-reports")}';
+ window.gl.mrWidgetData.false_positive_doc_url = '#{help_page_path('user/application_security/vulnerabilities/index')}';
+ window.gl.mrWidgetData.can_view_false_positive = '#{(Feature.enabled?(:vulnerability_flags, default_enabled: :yaml) && @merge_request.project.licensed_feature_available?(:sast_fp_reduction)).to_s}';
#js-vue-mr-widget.mr-widget
diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml
index b99714c1794..ea778517374 100644
--- a/app/views/projects/merge_requests/creations/_new_compare.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml
@@ -1,5 +1,5 @@
%h3.page-title
- New merge request
+ = _('New merge request')
= form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f|
- if params[:nav_source].present?
diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml
index 4aca13ae74a..eb5d052ec19 100644
--- a/app/views/projects/merge_requests/creations/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml
@@ -1,5 +1,5 @@
%h3.page-title
- New merge request
+ = _('New merge request')
= form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter
= f.hidden_field :source_project_id
diff --git a/app/views/projects/merge_requests/index.atom.builder b/app/views/projects/merge_requests/index.atom.builder
new file mode 100644
index 00000000000..36fe6bf759b
--- /dev/null
+++ b/app/views/projects/merge_requests/index.atom.builder
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+# rubocop: disable CodeReuse/ActiveRecord
+xml.title "#{@project.name} merge requests"
+xml.link href: url_for(safe_params), rel: "self", type: "application/atom+xml"
+xml.link href: project_merge_requests_url(@project), rel: "alternate", type: "text/html"
+xml.id project_merge_requests_url(@project)
+xml.updated @merge_requests.first.updated_at.xmlschema if @merge_requests.reorder(nil).any?
+
+xml << render(partial: 'projects/merge_requests/merge_request', collection: @merge_requests) if @merge_requests.reorder(nil).any?
+# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml
index 289f88c9705..41c6696789d 100644
--- a/app/views/projects/merge_requests/index.html.haml
+++ b/app/views/projects/merge_requests/index.html.haml
@@ -6,6 +6,9 @@
- page_title _("Merge requests")
- new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request')
+= content_for :meta_tags do
+ = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@project.name} merge requests")
+
= render 'projects/last_push'
- if @project.merge_requests.exists?
@@ -20,7 +23,7 @@
= render 'shared/issuable/bulk_update_sidebar', type: :merge_requests
.merge-requests-holder
- = render 'merge_requests'
+ = render 'merge_requests', new_merge_request_path: new_merge_request_path
- if new_merge_request_email
.gl-text-center.gl-pt-5.gl-pb-7
.js-issueable-by-email{ data: { initial_email: new_merge_request_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 6d1ba9e693b..7e260a03c5d 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -6,7 +6,6 @@
- page_description @merge_request.description_html
- page_card_attributes @merge_request.card_attributes
- suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions.md')
-- number_of_pipelines = @pipelines.size
- mr_action = j(params[:tab].presence || 'show')
- add_page_specific_style 'page_bundles/merge_requests'
- add_page_specific_style 'page_bundles/pipelines'
@@ -32,11 +31,11 @@
= tab_link_for @merge_request, :commits do
= _("Commits")
%span.badge.badge-pill.gl-badge.badge-muted.sm= @commits_count
- - if number_of_pipelines.nonzero?
+ - if @number_of_pipelines.nonzero?
= render "projects/merge_requests/tabs/tab", name: "pipelines", class: "pipelines-tab" do
= tab_link_for @merge_request, :pipelines do
= _("Pipelines")
- %span.badge.badge-pill.gl-badge.badge-muted.sm.js-pipelines-mr-count= number_of_pipelines
+ %span.badge.badge-pill.gl-badge.badge-muted.sm.js-pipelines-mr-count= @number_of_pipelines
= render "projects/merge_requests/tabs/tab", name: "diffs", class: "diffs-tab", id: "diffs-tab", qa_selector: "diffs_tab" do
= tab_link_for @merge_request, :diffs do
= _("Changes")
@@ -76,7 +75,7 @@
= render "projects/merge_requests/tabs/pane", name: "commits", id: "commits", class: "commits" do
-# This tab is always loaded via AJAX
= render "projects/merge_requests/tabs/pane", name: "pipelines", id: "pipelines", class: "pipelines" do
- - if number_of_pipelines.nonzero?
+ - if @number_of_pipelines.nonzero?
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
- params = request.query_parameters
- if Feature.enabled?(:default_merge_ref_for_diffs, @project, default_enabled: :yaml)
diff --git a/app/views/projects/packages/packages/show.html.haml b/app/views/projects/packages/packages/show.html.haml
index 42c31b3272f..ebdc9e654f6 100644
--- a/app/views/projects/packages/packages/show.html.haml
+++ b/app/views/projects/packages/packages/show.html.haml
@@ -6,7 +6,4 @@
.row
.col-12
- - if Feature.enabled?(:package_details_apollo, default_enabled: :yaml)
- #js-vue-packages-detail-new{ data: package_details_data(@project, @package) }
- - else
- #js-vue-packages-detail{ data: package_details_data(@project, @package, true) }
+ #js-vue-packages-detail-new{ data: package_details_data(@project, @package) }
diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
index 190bf9bf071..f6a0638ccd0 100644
--- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
+++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml
@@ -27,7 +27,7 @@
%td
.float-right.btn-group
- if can?(current_user, :play_pipeline_schedule, pipeline_schedule)
- = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('Play'), class: 'btn gl-button btn-default btn-icon' do
+ = link_to play_pipeline_schedule_path(pipeline_schedule), method: :post, title: _('Play'), class: 'btn gl-button btn-default btn-icon' do
= sprite_icon('play')
- if can?(current_user, :take_ownership_pipeline_schedule, pipeline_schedule)
= link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn gl-button btn-default' do
diff --git a/app/views/projects/pipeline_schedules/new.html.haml b/app/views/projects/pipeline_schedules/new.html.haml
index a2652304768..1b50090e445 100644
--- a/app/views/projects/pipeline_schedules/new.html.haml
+++ b/app/views/projects/pipeline_schedules/new.html.haml
@@ -1,4 +1,4 @@
-- breadcrumb_title "Schedules"
+- breadcrumb_title _('Schedules')
- @breadcrumb_link = namespace_project_pipeline_schedules_path(@project.namespace, @project)
- page_title _("New Pipeline Schedule")
- add_page_specific_style 'page_bundles/pipeline_schedules'
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index c1d48992500..23606e24563 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -34,8 +34,8 @@
%thead
%tr
%th= _('Status')
- %th= _('Job ID')
%th= _('Name')
+ %th= _('Job ID')
%th
%th= _('Coverage')
%th
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 30ebe4f20b6..8fd8d3cf540 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -28,4 +28,4 @@
= render "projects/pipelines/with_tabs", pipeline: @pipeline, stages: @stages, pipeline_has_errors: pipeline_has_errors
-.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
+.js-pipeline-details-vue{ data: { metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: @project.namespace, project_id: @project, format: :json), pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, graphql_resource_etag: graphql_etag_pipeline_path(@pipeline) } }
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 0239e408e87..12b2b33e364 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -3,7 +3,7 @@
.row.gl-mt-3
.col-lg-12
- - if can_invite_members_for_project?(@project) || can_invite_group_for_project?(@project)
+ - if can_invite_members_for_project?(@project)
.row
.col-md-12.col-lg-6.gl-display-flex
.gl-flex-direction-column.gl-flex-wrap.align-items-baseline
@@ -18,10 +18,7 @@
.col-md-12.col-lg-6
.gl-display-flex.gl-flex-wrap.gl-justify-content-end
- if can_admin_project_member?(@project)
- = link_to _("Import a project"),
- import_project_project_members_path(@project),
- class: "btn btn-default btn-md gl-button gl-mt-3 gl-sm-w-auto gl-w-full",
- title: _("Import members from another project")
+ .js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } }
- if @project.allowed_to_share_with_group?
.js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } }
- if can_admin_project_member?(@project)
diff --git a/app/views/projects/project_templates/_template.html.haml b/app/views/projects/project_templates/_template.html.haml
index 827ff62f8c3..5e4b1397dd3 100644
--- a/app/views/projects/project_templates/_template.html.haml
+++ b/app/views/projects/project_templates/_template.html.haml
@@ -8,9 +8,9 @@
.text-muted
= template.description
.controls.d-flex.align-items-center
- %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_event: "click_button", track_value: "" } }
+ %a.btn.gl-button.btn-default.gl-mr-3{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "template_preview", track_property: template.name, track_action: "click_button", track_value: "" } }
= _("Preview")
%label.btn.gl-button.btn-confirm.template-button.choose-template.gl-mb-0{ for: template.name }
- %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_event: "click_button", track_value: "" } }
+ %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "template_use", track_property: template.name, track_action: "click_button", track_value: "" } }
%span{ data: { qa_selector: 'use_template_button' } }
= _("Use template")
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 9702f9b08f2..fee0ca15808 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -4,22 +4,21 @@
.info-well
.well-segment
%p
- = s_("SlackService|This service allows users to perform common operations on this project by entering slash commands in Slack.")
+ = s_("SlackService|Perform common operations in this project by entering slash commands in Slack.")
= link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank' do
- = _("View documentation")
+ = _("Learn more.")
= sprite_icon('external-link')
%p.inline
- = s_("SlackService|See list of available commands in Slack after setting up this service, by entering")
- %kbd.inline /&lt;command&gt; help
+ = s_("SlackService|After setup, get a list of available Slack slash commands by entering")
+ %kbd.inline /&lt;command&gt; help
- if integration.project_level?
- %p= _("To set up this service:")
+ %p= _("To set up this integration:")
%ul.list-unstyled.indent-list
%li
- 1.
- = link_to 'https://my.slack.com/services/new/slash-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do
- Add a slash command
- = sprite_icon('external-link')
- in your Slack team with these options:
+ - slash_command_link_url = 'https://my.slack.com/services/new/slash-commands'
+ - slash_command_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: slash_command_link_url }
+ - slash_command_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: sprite_icon('external-link') }
+ = html_escape(s_('SlackService|1. %{slash_command_link_start}Add a slash command%{slash_command_link_end} in your Slack team using this information:')) % { slash_command_link_start: slash_command_link_start, slash_command_link_end: slash_command_link_end }
%hr
@@ -89,6 +88,6 @@
%ul.list-unstyled.indent-list
%li
- = html_escape(s_("SlackService|2. Paste the %{strong_open}Token%{strong_close} into the field below")) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
+ = html_escape(s_("SlackService|2. Paste the token from Slack in the %{strong_open}Token%{strong_close} field below.")) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
%li
- = html_escape(s_("SlackService|3. Select the %{strong_open}Active%{strong_close} checkbox, press %{strong_open}Save changes%{strong_close} and start using GitLab inside Slack!")) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
+ = html_escape(s_("SlackService|3. Select the %{strong_open}Active%{strong_close} checkbox, select %{strong_open}Save changes%{strong_close}, and start using slash commands in Slack!")) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe }
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 70626636ac0..75bd985560b 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -105,6 +105,6 @@
= expanded ? _('Collapse') : _('Expand')
%p
= _("Control which projects can be accessed by API requests authenticated with this project's CI_JOB_TOKEN CI/CD variable. It is a security risk to disable this feature, because unauthorized projects might attempt to retrieve an active token and access the API.")
- = link_to _('Learn more'), help_page_path('api/index', anchor: 'limit-gitlab-cicd-job-token-access'), target: '_blank', rel: 'noopener noreferrer'
+ = link_to _('Learn more'), help_page_path('ci/jobs/ci_job_token'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
= render 'ci/token_access/index'
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
index 4ef9e1bd6fb..6b2a1468eec 100644
--- a/app/views/projects/settings/operations/_error_tracking.html.haml
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -17,4 +17,5 @@
project: error_tracking_setting_project_json,
api_host: setting.api_host,
enabled: setting.enabled.to_json,
+ integrated: setting.integrated.to_json,
token: setting.token.present? ? '*' * 12 : nil } }
diff --git a/app/views/projects/tags/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
index 88594209c3b..c99f146ea7a 100644
--- a/app/views/projects/tags/releases/edit.html.haml
+++ b/app/views/projects/tags/releases/edit.html.haml
@@ -15,5 +15,5 @@
= render 'shared/notes/hints'
.error-alert
.gl-mt-5.gl-display-flex
- = f.submit 'Save changes', class: 'btn gl-button btn-confirm gl-mr-3'
- = link_to "Cancel", project_tag_path(@project, @tag.name), class: "btn gl-button btn-default btn-cancel"
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm gl-mr-3'
+ = link_to _('Cancel'), project_tag_path(@project, @tag.name), class: "btn gl-button btn-default btn-cancel"
diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml
new file mode 100644
index 00000000000..dfd46af0499
--- /dev/null
+++ b/app/views/projects/usage_quotas/index.html.haml
@@ -0,0 +1,19 @@
+- page_title s_("UsageQuota|Usage")
+
+%h3.page-title
+ = s_('UsageQuota|Usage Quotas')
+
+.row
+ .col-sm-12
+ = s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + '.'
+ %a{ href: help_page_path('user/usage_quotas.md') }
+ = s_('UsageQuota|Learn more about usage quotas') + '.'
+
+.top-area.scrolling-tabs-container.inner-page-scroll-tabs
+ %ul.nav.nav-tabs.nav-links.scrolling-tabs.separator.js-usage-quota-tabs{ role: 'tablist' }
+ %li.nav-item
+ %a.nav-link#storage-quota{ data: { toggle: "tab", action: '#storage-quota-tab' }, href: '#storage-quota-tab', 'aria-controls': '#storage-quota-tab', 'aria-selected': 'true' }
+ = s_('UsageQuota|Storage')
+.tab-content
+ .tab-pane#storage-quota-tab
+ #js-project-storage-count-app{ data: @storage_app_data }
diff --git a/app/views/projects/work_items/index.html.haml b/app/views/projects/work_items/index.html.haml
new file mode 100644
index 00000000000..052db598571
--- /dev/null
+++ b/app/views/projects/work_items/index.html.haml
@@ -0,0 +1,3 @@
+- page_title s_('WorkItem|Work Items')
+
+#js-work-items
diff --git a/app/views/registrations/experience_levels/show.html.haml b/app/views/registrations/experience_levels/show.html.haml
deleted file mode 100644
index 16e59757147..00000000000
--- a/app/views/registrations/experience_levels/show.html.haml
+++ /dev/null
@@ -1,29 +0,0 @@
-- page_title _('What’s your experience level?')
-- @hide_flash = true
-
-.gl-display-flex.gl-flex-direction-column.gl-align-items-center
- = image_tag 'learn-gitlab-avatar.jpg', width: '90'
-
- %h2.gl-text-center.gl-mt-3.gl-mb-3= _('Hello there')
- %p.gl-text-center.gl-font-lg.gl-mb-6= _('Welcome to the guided GitLab tour')
-
- %h3.gl-text-center.gl-font-lg.gl-mt-6.gl-mb-0= _('What describes you best?')
-
- .card-deck.gl-mt-6
- .card
- .card-body.gl-display-flex.gl-py-8.gl-pr-5.gl-pl-7
- .gl-align-self-center.gl-pr-6
- = 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 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
- .gl-align-self-center.gl-pr-6
- = 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 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/search/_category.html.haml b/app/views/search/_category.html.haml
index 7f8a530deb8..ca6f2369bd8 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -27,11 +27,11 @@
= search_filter_link 'snippet_titles', _("Titles and Descriptions"), search: { snippets: true, group_id: nil, project_id: nil }
- else
= search_filter_link 'projects', _("Projects"), data: { qa_selector: 'projects_tab' }
- = render_if_exists 'search/category_code'
+ = render_if_exists 'search/category_code' if feature_flag_tab_enabled?(:global_search_code_tab)
= render_if_exists 'search/epics_filter_link'
- = search_filter_link 'issues', _("Issues")
- = search_filter_link 'merge_requests', _("Merge requests")
- = render_if_exists 'search/category_wiki'
+ = search_filter_link 'issues', _("Issues") if feature_flag_tab_enabled?(:global_search_issues_tab)
+ = search_filter_link 'merge_requests', _("Merge requests") if feature_flag_tab_enabled?(:global_search_merge_requests_tab)
+ = render_if_exists 'search/category_wiki' if feature_flag_tab_enabled?(:global_search_wiki_tab)
= render_if_exists 'search/category_elasticsearch'
= search_filter_link 'milestones', _("Milestones")
= users
diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml
index 2f13d7d96c7..88a2ab4bb42 100644
--- a/app/views/search/results/_blob_data.html.haml
+++ b/app/views/search/results/_blob_data.html.haml
@@ -1,7 +1,7 @@
-.blob-result.gl-mt-3.gl-mb-5{ data: { qa_selector: 'result_item_content' } }
+.js-blob-result.gl-mt-3.gl-mb-5{ data: { qa_selector: 'result_item_content' } }
.file-holder.file-holder-top-border
.js-file-title.file-title{ data: { qa_selector: 'file_title_content' } }
- = link_to blob_link, data: {track_event: 'click_text', track_label: 'blob_path', track_property: 'search_result'} do
+ = link_to blob_link, data: {track_action: 'click_text', track_label: 'blob_path', track_property: 'search_result'} do
= sprite_icon('document')
%strong
= search_blob_title(project, path)
diff --git a/app/views/search/results/_commit.html.haml b/app/views/search/results/_commit.html.haml
index 3e5ea785aae..7aeeef5faed 100644
--- a/app/views/search/results/_commit.html.haml
+++ b/app/views/search/results/_commit.html.haml
@@ -1 +1 @@
-= render 'projects/commits/commit', project: commit.project, commit: commit, ref: nil, show_project_name: @project.nil?, link_data_attrs: {track_event: 'click_text', track_label: 'commit_title', track_property: 'search_result'}
+= render 'projects/commits/commit', project: commit.project, commit: commit, ref: nil, show_project_name: @project.nil?, link_data_attrs: {track_action: 'click_text', track_label: 'commit_title', track_property: 'search_result'}
diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml
index 63524bbf00e..5645fbfb238 100644
--- a/app/views/search/results/_issuable.html.haml
+++ b/app/views/search/results/_issuable.html.haml
@@ -3,7 +3,7 @@
%span.gl-display-flex.gl-align-items-center
%span.badge.badge-pill.gl-badge.sm{ class: "badge-#{issuable_state_to_badge_class(issuable)}" }= issuable_state_text(issuable)
= sprite_icon('eye-slash', css_class: 'gl-text-gray-500 gl-ml-2') if issuable.respond_to?(:confidential?) && issuable.confidential?
- = link_to issuable_path(issuable), data: { track_event: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do
+ = link_to issuable_path(issuable), data: { track_action: 'click_text', track_label: "#{issuable.class.name.downcase}_title", track_property: 'search_result' }, class: 'gl-w-full' do
%span.term.str-truncated.gl-font-weight-bold.gl-ml-2= issuable.title
.gl-text-gray-500.gl-my-3
= issuable_project_reference(issuable)
diff --git a/app/views/search/results/_milestone.html.haml b/app/views/search/results/_milestone.html.haml
index 6d4ce88a377..a57c94c19c9 100644
--- a/app/views/search/results/_milestone.html.haml
+++ b/app/views/search/results/_milestone.html.haml
@@ -1,6 +1,6 @@
.search-result-row
%h4
- = link_to project_milestone_path(milestone.project, milestone), data: {track_event: 'click_text', track_label: 'milestone_title', track_property: 'search_result'} do
+ = link_to project_milestone_path(milestone.project, milestone), data: {track_action: 'click_text', track_label: 'milestone_title', track_property: 'search_result'} do
%span.term.str-truncated= milestone.title
- if milestone.description.present?
diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml
index 8d5d8670b5c..67f16075649 100644
--- a/app/views/search/results/_note.html.haml
+++ b/app/views/search/results/_note.html.haml
@@ -18,7 +18,7 @@
- else
%span #{note.noteable_type.titleize} ##{noteable_identifier}
&middot;
- = link_to note.noteable.title, note_url, data: {track_event: 'click_text', track_label: 'noteable_title', track_property: 'search_result'}
+ = link_to note.noteable.title, note_url, data: {track_action: 'click_text', track_label: 'noteable_title', track_property: 'search_result'}
%span.note-headline-light.note-headline-meta
%span.system-note-separator
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index 55161ce333b..b59275c35df 100644
--- a/app/views/search/results/_wiki_blob.html.haml
+++ b/app/views/search/results/_wiki_blob.html.haml
@@ -3,7 +3,7 @@
%div{ class: 'search-result-row gl-pb-3! gl-mt-5 gl-mb-0!' }
%span.gl-display-flex.gl-align-items-center
- = link_to wiki_blob_link, data: { track_event: 'click_text', track_label: "wiki_title", track_property: 'search_result' }, class: 'gl-w-full' do
+ = link_to wiki_blob_link, data: { track_action: 'click_text', track_label: "wiki_title", track_property: 'search_result' }, class: 'gl-w-full' do
%span.term.str-truncated.gl-font-weight-bold= ::Gitlab::Git::Wiki::GollumSlug.canonicalize_filename(wiki_blob.path)
.description.term.col-sm-10.gl-px-0
= simple_search_highlight_and_truncate(wiki_blob.data, @search_term)
diff --git a/app/views/shared/_help_dropdown_forum_link.html.haml b/app/views/shared/_help_dropdown_forum_link.html.haml
index 351c875475a..f3c69a7c897 100644
--- a/app/views/shared/_help_dropdown_forum_link.html.haml
+++ b/app/views/shared/_help_dropdown_forum_link.html.haml
@@ -1,2 +1,2 @@
= link_to _("Community forum"), "https://forum.gitlab.com/", target: '_blank', class: 'text-nowrap',
- rel: 'noopener noreferrer', data: { 'track_event': 'click_forum', 'track_property': 'question_menu' }
+ rel: 'noopener noreferrer', data: { 'track_action': 'click_forum', 'track_property': 'question_menu' }
diff --git a/app/views/shared/_check_recovery_settings.html.haml b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
index 2ba0cca9ef6..d4764d1a5d9 100644
--- a/app/views/shared/_check_recovery_settings.html.haml
+++ b/app/views/shared/_two_factor_auth_recovery_settings_check.html.haml
@@ -1,7 +1,7 @@
= render 'shared/global_alert',
variant: :warning,
alert_class: 'js-recovery-settings-callout',
- alert_data: { feature_id: 'account_recovery_regular_check', dismiss_endpoint: user_callouts_path, defer_links: 'true' },
+ alert_data: { feature_id: UserCalloutsHelper::TWO_FACTOR_AUTH_RECOVERY_SETTINGS_CHECK, dismiss_endpoint: user_callouts_path, defer_links: 'true' },
close_button_data: { testid: 'close-account-recovery-regular-check-callout' } do
.gl-alert-body
= s_('Profiles|Ensure you have two-factor authentication recovery codes stored in a safe place.')
diff --git a/app/views/shared/_visibility_level.html.haml b/app/views/shared/_visibility_level.html.haml
index 84ce40e240c..3e30dcaf35a 100644
--- a/app/views/shared/_visibility_level.html.haml
+++ b/app/views/shared/_visibility_level.html.haml
@@ -7,7 +7,7 @@
= _('Who can see this group?')
- visibility_docs_path = help_page_path('public_access/public_access')
- docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: visibility_docs_path }
- = s_('Check the %{docs_link_start}documentation%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
+ = _('%{docs_link_start}Learn about visibility levels.%{docs_link_end}').html_safe % { docs_link_start: docs_link_start, docs_link_end: '</a>'.html_safe }
- if can_change_visibility_level
= render('shared/visibility_radios', model_method: :visibility_level, form: f, selected_level: visibility_level, form_model: form_model)
- else
diff --git a/app/views/shared/_visibility_radios.html.haml b/app/views/shared/_visibility_radios.html.haml
index 90b12557bc8..f48bfcd0e72 100644
--- a/app/views/shared/_visibility_radios.html.haml
+++ b/app/views/shared/_visibility_radios.html.haml
@@ -3,7 +3,7 @@
- available_visibility_levels.each do |level|
.form-check
- = form.radio_button model_method, level, checked: (selected_level == level), class: 'form-check-input', data: { track_label: "blank_project", track_event: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" }
+ = form.radio_button model_method, level, checked: (selected_level == level), class: 'form-check-input', data: { track_label: "blank_project", track_action: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" }
= form.label "#{model_method}_#{level}", class: 'form-check-label' do
= visibility_level_icon(level)
.option-title
diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml
index 33d6b9573d4..c096044e439 100644
--- a/app/views/shared/access_tokens/_table.html.haml
+++ b/app/views/shared/access_tokens/_table.html.haml
@@ -51,7 +51,7 @@
= _('Expired')
- else
%span{ class: ('text-warning' if token.expires_soon?) }
- = _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) }
+ = time_ago_with_tooltip(token.expires_at)
- else
%span.token-never-expires-label= _('Never')
- if project
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index a49c17e9265..98752345074 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -1,5 +1,4 @@
- board = local_assigns.fetch(:board, nil)
-- group = local_assigns.fetch(:group, false)
- @no_breadcrumb_container = true
- @no_container = true
- @content_wrapper_class = "#{@content_wrapper_class} gl-relative"
@@ -18,8 +17,5 @@
- add_page_specific_style 'page_bundles/boards'
= render 'shared/issuable/search_bar', type: :boards, board: board
-#board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
- %board-content{ ":lists" => "state.lists", ":disabled" => "disabled" }
- - if !is_epic_board && !Feature.enabled?(:graphql_board_lists, default_enabled: :yaml)
- = render "shared/boards/components/sidebar", group: group
- %board-settings-sidebar
+
+#js-issuable-board-app{ data: board_data }
diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml
deleted file mode 100644
index 8976e89b3d3..00000000000
--- a/app/views/shared/boards/components/_sidebar.html.haml
+++ /dev/null
@@ -1,27 +0,0 @@
-%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json }
- %transition{ name: "boards-sidebar-slide" }
- %aside.right-sidebar.right-sidebar-expanded.boards-sidebar{ "v-show" => "showSidebar", 'aria-label': s_('Boards|Board'), 'data-testid': 'issue-boards-sidebar' }
- .issuable-sidebar
- .block.issuable-sidebar-header.position-relative
- %span.issuable-header-text.hide-collapsed.float-left
- %strong.bold
- {{ issue.title }}
- %br/
- %span
- = render_if_exists "shared/boards/components/sidebar/issue_project_path"
- = precede "#" do
- {{ issue.iid }}
- %a.gutter-toggle.position-absolute.position-top-0.position-right-0{ role: "button",
- href: "#",
- "@click.prevent" => "closeSidebar",
- "aria-label" => "Toggle sidebar" }
- = custom_icon("icon_close", size: 15)
- .js-issuable-update
- = render "shared/boards/components/sidebar/assignee"
- = render_if_exists "shared/boards/components/sidebar/epic"
- = render "shared/boards/components/sidebar/milestone"
- = render "shared/boards/components/sidebar/time_tracker"
- = render "shared/boards/components/sidebar/due_date"
- = render "shared/boards/components/sidebar/labels"
- = render_if_exists "shared/boards/components/sidebar/weight"
- = render "shared/boards/components/sidebar/notifications"
diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml
deleted file mode 100644
index 79817025565..00000000000
--- a/app/views/shared/boards/components/sidebar/_assignee.html.haml
+++ /dev/null
@@ -1,10 +0,0 @@
-- dropdown_options = assignees_dropdown_options('issue')
-- relative_url = Gitlab.config.gitlab.relative_url_root || '/'
-
-.block.assignee{ ref: "assigneeBlock" }
- %template{ "v-if" => "issue.assignees" }
- %sidebar-assignees-widget{ ":iid" => "String(issue.iid)",
- ":full-path" => "issue.path.split('/-/')[0].substring(1).replace(`#{relative_url}`, '')",
- ":initial-assignees" => "issue.assignees",
- ":allow-multiple-assignees" => "!Boolean(#{dropdown_options[:data][:"max-select"]})",
- "@assignees-updated" => "setAssignees" }
diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml
deleted file mode 100644
index ab4d22ac03d..00000000000
--- a/app/views/shared/boards/components/sidebar/_due_date.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-.block.due_date
- .title.gl-h-5.gl-display-flex.gl-align-items-center
- = _("Due date")
- - if can_admin_issue?
- = loading_icon(css_class: 'gl-ml-2 block-loading')
- = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link gl-ml-auto"
- .value
- .value-content
- %span.no-value{ "v-if" => "!issue.dueDate" }
- = _("None")
- %span.bold{ "v-if" => "issue.dueDate" }
- {{ issue.dueDate | due-date }}
- - if can_admin_issue?
- %span.no-value.js-remove-due-date-holder{ "v-if" => "issue.dueDate" }
- \-
- %a.js-remove-due-date{ href: "#", role: "button" }
- = _('remove due date')
- - if can_admin_issue?
- .selectbox
- %input{ type: "hidden",
- name: "issue[due_date]",
- ":value" => "issue.dueDate" }
- .dropdown
- %button.dropdown-menu-toggle.js-due-date-select.js-issue-boards-due-date{ type: 'button',
- data: { toggle: 'dropdown', field_name: "issue[due_date]", ability_name: "issue" } }
- %span.dropdown-toggle-text= _("Due date")
- = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
- .dropdown-menu.dropdown-menu-due-date
- = dropdown_title(_('Due date'))
- = dropdown_content do
- .js-due-date-calendar
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
deleted file mode 100644
index 5af52d4de23..00000000000
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ /dev/null
@@ -1,34 +0,0 @@
-.block.labels
- .title.gl-h-5.gl-display-flex.gl-align-items-center
- = _("Labels")
- - if can_admin_issue?
- = loading_icon(css_class: 'gl-ml-2 block-loading')
- = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link gl-ml-auto"
- .value.issuable-show-labels.dont-hide
- %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
- = _("None")
- %span{ "v-for" => "label in issue.labels" }
- %gl-label{ ":key" => "label.id",
- ":background-color" => "label.color",
- ":title" => "label.title",
- ":description" => "label.description",
- ":scoped" => "showScopedLabels(label)" }
-
- - if can_admin_issue?
- .selectbox
- %input{ type: "hidden",
- name: "issue[label_names][]",
- "v-for" => "label in issue.labels",
- ":value" => "label.id" }
- .dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
- ":data-selected" => "selectedLabels",
- ":data-labels" => "issue.assignableLabelsEndpoint",
- data: label_dropdown_data(@project, namespace_path: @namespace_path, field_name: "issue[label_names][]") }
- %span.dropdown-toggle-text
- {{ labelDropdownTitle }}
- = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
- .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable.dropdown-extended-height
- = render partial: "shared/issuable/label_page_default"
- - if can?(current_user, :admin_label, current_board_parent)
- = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true }
diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml
deleted file mode 100644
index 6143f1d5afe..00000000000
--- a/app/views/shared/boards/components/sidebar/_milestone.html.haml
+++ /dev/null
@@ -1,29 +0,0 @@
-.block.milestone
- .title.gl-h-5.gl-display-flex.gl-align-items-center
- = _("Milestone")
- - if can_admin_issue?
- = loading_icon(css_class: 'gl-ml-2 block-loading')
- = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link gl-ml-auto"
- .value
- %span.no-value{ "v-if" => "!issue.milestone" }
- = _("None")
- %span.bold.has-tooltip{ "v-if" => "issue.milestone" }
- {{ issue.milestone.title }}
- - if can_admin_issue?
- .selectbox
- %input{ type: "hidden",
- ":value" => "issue.milestone.id",
- name: "issue[milestone_id]",
- "v-if" => "issue.milestone" }
- .dropdown
- %button.dropdown-menu-toggle.js-milestone-select.js-issue-board-sidebar{ type: "button", data: { toggle: "dropdown", show_no: "true", field_name: "issue[milestone_id]", ability_name: "issue", use_id: "true", default_no: "true" },
- ":data-selected" => "milestoneTitle",
- ":data-issuable-id" => "issue.iid",
- ":data-project-id" => "issue.project_id" }
- = _("Milestone")
- = sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
- .dropdown-menu.dropdown-select.dropdown-menu-selectable
- = dropdown_title(_("Assign milestone"))
- = dropdown_filter(_("Search milestones"))
- = dropdown_content
- = dropdown_loading
diff --git a/app/views/shared/boards/components/sidebar/_notifications.html.haml b/app/views/shared/boards/components/sidebar/_notifications.html.haml
deleted file mode 100644
index 333dd1a00b4..00000000000
--- a/app/views/shared/boards/components/sidebar/_notifications.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- if current_user
- .block.subscriptions
- %subscriptions{ ":loading" => "issue.isFetching && issue.isFetching.subscriptions",
- ":subscribed" => "issue.subscribed",
- ":id" => "issue.id" }
diff --git a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
deleted file mode 100644
index eea3ec35000..00000000000
--- a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
+++ /dev/null
@@ -1,6 +0,0 @@
-.block.time-tracking
- %time-tracker{ ":limit-to-hours" => "timeTrackingLimitToHours",
- ":issuable-id" => "issue.id ? issue.id.toString() : ''",
- ":issuable-iid" => "issue.iid ? issue.iid.toString() : ''",
- ":full-path" => "issue.project ? issue.project.fullPath : ''",
- "root-path" => "#{root_url}" }
diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml
index e7bbb351633..652da4b396a 100644
--- a/app/views/shared/deploy_tokens/_form.html.haml
+++ b/app/views/shared/deploy_tokens/_form.html.haml
@@ -48,7 +48,7 @@
.text-secondary= s_('DeployTokens|Allows read-only access to the package registry.')
%fieldset.form-group.form-check
- = f.check_box :write_package_registry, class: 'form-check-input'
+ = f.check_box :write_package_registry, class: 'form-check-input', data: { qa_selector: 'deploy_token_write_package_registry_checkbox' }
= f.label :write_package_registry, 'write_package_registry', class: 'label-bold form-check-label'
.text-secondary= s_('DeployTokens|Allows read and write access to the package registry.')
diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml
index fe32fcf94d0..db9c646b694 100644
--- a/app/views/shared/deploy_tokens/_table.html.haml
+++ b/app/views/shared/deploy_tokens/_table.html.haml
@@ -20,7 +20,7 @@
%td
- if token.expires?
%span{ class: ('text-warning' if token.expires_soon?) }
- In #{distance_of_time_in_words_to_now(token.expires_at)}
+ = time_ago_with_tooltip(token.expires_at)
- else
%span.token-never-expires-label= _('Never')
%td= token.scopes.present? ? token.scopes.join(', ') : _('no scopes selected')
diff --git a/app/views/shared/doorkeeper/applications/_form.html.haml b/app/views/shared/doorkeeper/applications/_form.html.haml
index 91a32b55542..180c658dbdc 100644
--- a/app/views/shared/doorkeeper/applications/_form.html.haml
+++ b/app/views/shared/doorkeeper/applications/_form.html.haml
@@ -18,6 +18,12 @@
%span.form-text.text-muted
= _('The application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.')
+ .form-group.form-check
+ = f.check_box :expire_access_tokens, class: 'form-check-input'
+ = f.label :expire_access_tokens, class: 'label-bold form-check-label'
+ %span.form-text.text-muted
+ = _('Access tokens expire after 2 hours. A refresh token may be used at any time to generate a new access token. Non-expiring access tokens are deprecated. Clear this setting to enable backward compatibility.')
+
.form-group
= f.label :scopes, class: 'label-bold'
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: @application, scopes: @scopes
diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml
index 879447f16ae..72db4d4c846 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -20,7 +20,7 @@
= _("To widen your search, change or remove filters above")
.text-center
- if can_create_merge_request
- = link_to _("New merge request"), project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
+ = link_to _("New merge request"), button_path || project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
- elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0
%h4.text-center
= _("There are no open merge requests")
@@ -28,7 +28,7 @@
= _("To keep this project going, create a new merge request")
.text-center
- if can_create_merge_request
- = link_to _("New merge request"), project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
+ = link_to _("New merge request"), button_path || project_new_merge_request_path(@project), class: "gl-button btn btn-confirm", title: _("New merge request")
- elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0
%h4.text-center
= _("There are no closed merge requests")
diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
deleted file mode 100644
index 74b064648c0..00000000000
--- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml
+++ /dev/null
@@ -1,8 +0,0 @@
-.dropdown.gl-display-flex.gl-align-items-center.gl-ml-3#js-add-list
- %button.gl-button.btn.btn-confirm.js-new-board-list{ type: "button", data: board_list_data }
- Add list
- .dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
- = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- - if can?(current_user, :admin_label, board.resource_parent)
- = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true, add_list: true, add_list_class: 'd-none' }
- = dropdown_loading
diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml
index c3e4c3a15cc..f0e4b915ac8 100644
--- a/app/views/shared/issuable/_feed_buttons.html.haml
+++ b/app/views/shared/issuable/_feed_buttons.html.haml
@@ -1,4 +1,8 @@
-= link_to safe_params.merge(rss_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') do
+- show_calendar_button = local_assigns.fetch(:show_calendar_button, true)
+
+= link_to safe_params.merge(rss_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') , 'aria-label': _('Subscribe to RSS feed') do
= sprite_icon('rss', css_class: 'qa-rss-icon')
-= link_to safe_params.merge(calendar_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do
- = sprite_icon('calendar')
+
+- if show_calendar_button
+ = link_to safe_params.merge(calendar_url_options), class: 'btn gl-button btn-default btn-icon has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar'), 'aria-label': _('Subscribe to calendar') do
+ = sprite_icon('calendar')
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index dc93442d6cd..cb03bd4c473 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -66,7 +66,7 @@
- if issuable.new_record?
= form.submit "Create #{issuable.class.model_name.human.downcase}", class: 'gl-button btn btn-confirm gl-mr-2', data: { qa_selector: 'issuable_create_button' }
- else
- = form.submit 'Save changes', class: 'gl-button btn btn-confirm gl-mr-2'
+ = form.submit _('Save changes'), class: 'gl-button btn btn-confirm gl-mr-2'
- if issuable.new_record?
= link_to _('Cancel'), polymorphic_path([@project, issuable.class]), class: 'btn gl-button btn-default'
diff --git a/app/views/shared/issuable/_issuable.atom.builder b/app/views/shared/issuable/_issuable.atom.builder
new file mode 100644
index 00000000000..931fb74dc00
--- /dev/null
+++ b/app/views/shared/issuable/_issuable.atom.builder
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+builder.title truncate(issuable.title, length: 80)
+builder.updated issuable.updated_at.xmlschema
+builder.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon_for_user(issuable.author))
+
+builder.author do
+ builder.name issuable.author_name
+ builder.email issuable.author_public_email
+end
+
+builder.summary issuable.title
+builder.description truncate(issuable.description, length: 240) if issuable.description
+builder.content issuable.description if issuable.description
+builder.milestone issuable.milestone.title if issuable.milestone
+
+unless issuable.labels.empty?
+ builder.labels do
+ issuable.labels.each do |label|
+ builder.label label.name
+ end
+ end
+end
+
+if issuable.assignees.any?
+ builder.assignees do
+ issuable.assignees.each do |assignee|
+ builder.assignee do
+ builder.name assignee.name
+ builder.email assignee.public_email
+ end
+ end
+ end
+
+ builder.assignee do
+ builder.name issuable.assignees.first.name
+ builder.email issuable.assignees.first.public_email
+ end
+end
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 737a0ff8c5b..e6c4b3f4814 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -207,10 +207,7 @@
#js-board-epics-swimlanes-toggle
.js-board-config{ data: { can_admin_list: user_can_admin_list.to_s, has_scope: board.scoped?.to_s } }
- if user_can_admin_list
- - if Feature.enabled?(:board_new_list, board.resource_parent, default_enabled: :yaml) || board.to_type == "EpicBoard"
- .js-create-column-trigger{ data: board_list_data }
- - else
- = render 'shared/issuable/board_create_list_dropdown', board: board
+ .js-create-column-trigger{ data: board_list_data }
#js-toggle-focus-btn
- elsif type != :productivity_analytics && show_sorting_dropdown
= render 'shared/issuable/sort_dropdown'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index c76aa176696..1e8724c3448 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -88,7 +88,7 @@
= custom_icon('icon_arrow_right')
.dropdown.sidebar-move-issue-dropdown.hide-collapsed
%button.gl-button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button',
- data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_event: "click_button", track_value: "" } }
+ data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_action: "click_button", track_value: "" } }
= _('Move issue')
.dropdown-menu.dropdown-menu-selectable.dropdown-extended-height
= dropdown_title(_('Move issue'))
diff --git a/app/views/shared/issuable/_sidebar_user_dropdown.html.haml b/app/views/shared/issuable/_sidebar_user_dropdown.html.haml
index 84d2fc033c8..c058e7ebe3e 100644
--- a/app/views/shared/issuable/_sidebar_user_dropdown.html.haml
+++ b/app/views/shared/issuable/_sidebar_user_dropdown.html.haml
@@ -6,7 +6,7 @@
- options[:footer_content] = true
- options[:wrapper_class] = local_assigns.fetch(:wrapper_class)
- options[:toggle_class] += ' js-invite-members-track'
- - data['track-event'] = 'show_invite_members'
+ - data['track-action'] = 'show_invite_members'
- data['track-label'] = local_assigns.fetch(:track_label)
= dropdown_tag(data['dropdown-title'], options: options) do
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index f5bf010e4db..5742f22ce05 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -21,5 +21,6 @@
= sortable_item(sort_title_merged_date, page_filter_path(sort: sort_value_merged_date), sort_title) if viewing_merge_requests
= sortable_item(sort_title_closed_date, page_filter_path(sort: sort_value_closed_date), sort_title) if viewing_merge_requests
= sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues
+ = sortable_item(sort_title_title, page_filter_path(sort: sort_value_title), sort_title) if viewing_issues
= render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title)
= issuable_sort_direction_button(sort_value)
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index 1043eb49752..2f05d272ca3 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -1,13 +1,10 @@
- project = local_assigns.fetch(:project)
- issuable = local_assigns.fetch(:issuable)
- presenter = local_assigns.fetch(:presenter)
-
-- return unless can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable)
-
- has_due_date = issuable.has_attribute?(:due_date)
- form = local_assigns.fetch(:form)
-- if issuable.respond_to?(:confidential)
+- if issuable.respond_to?(:confidential) && can?(current_user, :set_confidentiality, issuable)
.form-group.row
.offset-sm-2.col-sm-10
.form-check
@@ -15,39 +12,40 @@
= 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.
-%hr
-.row
- %div{ class: (has_due_date ? "col-lg-6" : "col-12") }
- .form-group.row.merge-request-assignee
- = render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date
-
- - 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, presenter: presenter
+- if can?(current_user, :"set_#{issuable.to_ability_name}_metadata", issuable)
+ %hr
+ .row
+ %div{ class: (has_due_date ? "col-lg-6" : "col-12") }
+ .form-group.row.merge-request-assignee
+ = render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date
- = render_if_exists "shared/issuable/form/epic", issuable: issuable, form: form, project: project
-
- - if issuable.supports_milestone?
- .form-group.row.issue-milestone
- = form.label :milestone_id, "Milestone", 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
- = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
+ - 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, presenter: presenter
- .form-group.row
- = form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
- = form.hidden_field :label_ids, multiple: true, value: ''
- .col-sm-10{ class: "#{"col-md-8" if has_due_date}" }
- .issuable-form-select-holder
- = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
+ = render_if_exists "shared/issuable/form/epic", issuable: issuable, form: form, project: project
- = render_if_exists "shared/issuable/form/merge_request_blocks", issuable: issuable, form: form
+ - if issuable.supports_milestone?
+ .form-group.row.issue-milestone
+ = form.label :milestone_id, "Milestone", 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
+ = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
- - if has_due_date
- .col-lg-6
- = render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form
.form-group.row
- = form.label :due_date, "Due date", class: "col-form-label col-md-2 col-lg-4"
- .col-8
+ = form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
+ = form.hidden_field :label_ids, multiple: true, value: ''
+ .col-sm-10{ class: "#{"col-md-8" if has_due_date}" }
.issuable-form-select-holder
- = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off'
+ = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
+
+ = render_if_exists "shared/issuable/form/merge_request_blocks", issuable: issuable, form: form
+
+ - if has_due_date
+ .col-lg-6
+ = render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form
+ .form-group.row
+ = form.label :due_date, "Due date", class: "col-form-label col-md-2 col-lg-4"
+ .col-8
+ .issuable-form-select-holder
+ = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off'
diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml
index 3b4ab22ce32..f5f6f32d5ba 100644
--- a/app/views/shared/issuable/form/_type_selector.html.haml
+++ b/app/views/shared/issuable/form/_type_selector.html.haml
@@ -21,7 +21,7 @@
%li.js-filter-issuable-type
= link_to new_project_issue_path(@project), class: ("is-active" if issuable.issue?) do
#{sprite_icon(work_item_type_icon(:issue), css_class: 'gl-icon')} #{_("Issue")}
- %li.js-filter-issuable-type{ data: { track: { event: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
+ %li.js-filter-issuable-type{ data: { track: { action: "select_issue_type_incident", label: "select_issue_type_incident_dropdown_option" } } }
= link_to new_project_issue_path(@project, { issuable_template: 'incident', issue: { issue_type: 'incident' } }), class: ("is-active" if issuable.incident?) do
#{sprite_icon(work_item_type_icon(:incident), css_class: 'gl-icon')} #{_("Incident")}
diff --git a/app/views/shared/issue_type/_details_header.html.haml b/app/views/shared/issue_type/_details_header.html.haml
index a25e35cdcd4..eca61819cca 100644
--- a/app/views/shared/issue_type/_details_header.html.haml
+++ b/app/views/shared/issue_type/_details_header.html.haml
@@ -15,7 +15,7 @@
= _('Open')
.issuable-meta
- #js-issuable-header-warnings
+ #js-issuable-header-warnings{ data: { hidden: issue_hidden?(issuable).to_s } }
= issuable_meta(issuable, @project)
%a.btn.gl-button.btn-default.btn-icon.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 604aac9237c..6f65dbe4811 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -28,7 +28,7 @@
= render_suggested_colors
.form-actions
- if @label.persisted?
- = f.submit 'Save changes', class: 'btn gl-button btn-confirm js-save-button'
+ = f.submit _('Save changes'), class: 'btn gl-button btn-confirm js-save-button'
- else
= f.submit 'Create label', class: 'btn gl-button btn-confirm js-save-button qa-label-create-button'
- = link_to 'Cancel', back_path, class: 'btn gl-button btn-default btn-cancel'
+ = link_to _('Cancel'), back_path, class: 'btn gl-button btn-default btn-cancel'
diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml
index a983a736a1e..8600db25e65 100644
--- a/app/views/shared/members/_access_request_links.html.haml
+++ b/app/views/shared/members/_access_request_links.html.haml
@@ -5,13 +5,11 @@
= link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(source), qa_selector: 'leave_group_link' },
- class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500 js-leave-link'
+ class: 'js-leave-link'
- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord
= link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
method: :delete,
- data: { confirm: remove_member_message(requester) },
- class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500'
+ data: { confirm: remove_member_message(requester) }
- elsif source.request_access_enabled && can?(current_user, :request_access, source)
= link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
- method: :post,
- class: '.gl-pl-3.gl-border-l-1.gl-border-l-solid.gl-border-l-gray-500'
+ method: :post
diff --git a/app/views/shared/runners/_runner_details.html.haml b/app/views/shared/runners/_runner_details.html.haml
index 672f0b6a83f..a7b2947057d 100644
--- a/app/views/shared/runners/_runner_details.html.haml
+++ b/app/views/shared/runners/_runner_details.html.haml
@@ -60,4 +60,4 @@
- if runner.contacted_at
= time_ago_with_tooltip runner.contacted_at
- else
- = s_('Never')
+ = _('Never')
diff --git a/app/views/shared/wikis/pages.html.haml b/app/views/shared/wikis/pages.html.haml
index c1918198594..0a8ca309823 100644
--- a/app/views/shared/wikis/pages.html.haml
+++ b/app/views/shared/wikis/pages.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs "Wiki", wiki_path(@wiki)
+- add_to_breadcrumbs _('Wiki'), wiki_path(@wiki)
- breadcrumb_title s_("Wiki|Pages")
- page_title s_("Wiki|Pages"), _("Wiki")
- sort_title = wiki_sort_title(params[:sort])
diff --git a/app/views/sherlock/queries/_backtrace.html.haml b/app/views/sherlock/queries/_backtrace.html.haml
index ff5a6b73e47..425113ba325 100644
--- a/app/views/sherlock/queries/_backtrace.html.haml
+++ b/app/views/sherlock/queries/_backtrace.html.haml
@@ -8,7 +8,7 @@
%li
%strong
- if defined?(BetterErrors)
- = link_to(location.path, BetterErrors.editor[location.path, location.line])
+ = link_to(location.path, BetterErrors.editor.url(location.path, location.line))
- else
= location.path
%small.light
diff --git a/app/views/sherlock/queries/_general.html.haml b/app/views/sherlock/queries/_general.html.haml
index cd810ae10ad..a16314213c4 100644
--- a/app/views/sherlock/queries/_general.html.haml
+++ b/app/views/sherlock/queries/_general.html.haml
@@ -16,7 +16,7 @@
#{t('sherlock.origin')}:
%strong
- if defined?(BetterErrors)
- = link_to(frame.path, BetterErrors.editor[frame.path, frame.line])
+ = link_to(frame.path, BetterErrors.editor.url(frame.path, frame.line))
- else
= frame.path
%small.light
diff --git a/app/views/users/_middle_dot_divider.html.haml b/app/views/users/_middle_dot_divider.html.haml
new file mode 100644
index 00000000000..540a200921f
--- /dev/null
+++ b/app/views/users/_middle_dot_divider.html.haml
@@ -0,0 +1,5 @@
+- stacking = local_assigns.delete(:stacking)
+- breakpoint = local_assigns.delete(:breakpoint)
+
+%div{ class: middle_dot_divider_classes(stacking, breakpoint), **local_assigns }
+ = yield
diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml
index c431a72d0e7..3b0186e84e1 100644
--- a/app/views/users/_profile_basic_info.html.haml
+++ b/app/views/users/_profile_basic_info.html.haml
@@ -1,6 +1,6 @@
-%p.mb-1.mb-sm-2.mt-2.mt-sm-3
- %span.middle-dot-divider
+.gl-text-gray-900.gl-mt-4
+ = render 'middle_dot_divider' do
@#{@user.username}
- if can?(current_user, :read_user_profile, @user)
- %span.middle-dot-divider
+ = render 'middle_dot_divider' do
= s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 363909c54e2..20cbe08225e 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -2,7 +2,7 @@
- @hide_breadcrumbs = true
- @no_container = true
- page_title user_display_name(@user)
-- page_description @user.bio_html
+- page_description @user.bio
- header_title @user.name, user_path(@user)
- page_itemtype 'http://schema.org/Person'
- link_classes = "flex-grow-1 mx-1 "
@@ -26,6 +26,13 @@
= link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referer), class: link_classes + 'btn gl-button btn-default btn-icon',
title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= sprite_icon('error')
+ - verified_gpg_keys = @user.gpg_keys.select(&:verified?)
+ - if verified_gpg_keys.any?
+ = link_to user_gpg_keys_path,
+ class: link_classes + 'btn btn-default btn-md gl-button btn-icon has-tooltip',
+ title: n_('View public GPG key', 'View public GPG keys', verified_gpg_keys.length),
+ data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
+ = sprite_icon('key', css_class: 'gl-button-icon gl-icon')
- if can?(current_user, :read_user_profile, @user)
= link_to user_path(@user, rss_url_options), class: link_classes + 'btn gl-button btn-default btn-icon has-tooltip',
title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
@@ -71,56 +78,56 @@
= emoji_icon(@user.status.emoji, class: 'gl-mr-2')
= markdown_field(@user.status, :message)
= render "users/profile_basic_info"
- .cover-desc.cgray.mb-1.mb-sm-2
+ .gl-text-gray-900.mb-1.mb-sm-2
- unless @user.location.blank?
- .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mb-1.mb-sm-0{ itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' }
+ = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do
= sprite_icon('location', css_class: 'fgray')
%span{ itemprop: 'addressLocality' }
= @user.location
- .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mb-1.mb-sm-0
+ = render 'middle_dot_divider', stacking: true do
= sprite_icon('clock', css_class: 'fgray')
%span
= local_time(@user.timezone)
- unless work_information(@user).blank?
- .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline
+ = render 'middle_dot_divider', stacking: true do
= sprite_icon('work', css_class: 'fgray')
%span
= work_information(@user, with_schema_markup: true)
- .cover-desc.cgray.mb-1.mb-sm-2
+ .gl-text-gray-900
- unless @user.skype.blank?
- .profile-link-holder.middle-dot-divider
- = link_to "skype:#{@user.skype}", title: "Skype" do
+ = render 'middle_dot_divider' do
+ = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do
= sprite_icon('skype')
- unless @user.linkedin.blank?
- .profile-link-holder.middle-dot-divider
- = link_to linkedin_url(@user), title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = render 'middle_dot_divider' do
+ = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
= sprite_icon('linkedin')
- unless @user.twitter.blank?
- .profile-link-holder.middle-dot-divider-sm
- = link_to twitter_url(@user), title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
+ = render 'middle_dot_divider', breakpoint: 'sm' do
+ = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do
= sprite_icon('twitter')
- unless @user.website_url.blank?
- .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
+ = render 'middle_dot_divider', stacking: true do
- if Feature.enabled?(:security_auto_fix) && @user.bot?
= sprite_icon('question', css_class: 'gl-text-blue-600')
- = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
+ = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
- unless @user.public_email.blank?
- .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mt-1.mt-sm-0
- = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link', itemprop: 'email'
- .cover-desc.gl-text-gray-900.gl-mb-2.mb-sm-2
+ = render 'middle_dot_divider', stacking: true do
+ = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email'
+ .gl-text-gray-900
= sprite_icon('users', css_class: 'gl-vertical-align-middle gl-text-gray-500')
- .profile-link-holder.middle-dot-divider
- = link_to user_followers_path, class: 'text-link' do
+ = render 'middle_dot_divider' do
+ = link_to user_followers_path do
- count = @user.followers.count
= n_('1 follower', '%{count} followers', count) % { count: count }
- .profile-link-holder.middle-dot-divider
- = link_to user_following_path, class: 'text-link', data: { qa_selector: 'following_link' } do
+ = render 'middle_dot_divider' do
+ = link_to user_following_path, data: { qa_selector: 'following_link' } do
= @user.followees.count
= _('following')
- if @user.bio.present?
- .cover-desc.cgray
+ .gl-text-gray-900
.profile-user-bio
- = markdown(@user.bio_html)
+ = @user.bio
- unless profile_tabs.empty?
diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml
index 73d0f51f9ac..771ee693120 100644
--- a/app/views/users/terms/index.html.haml
+++ b/app/views/users/terms/index.html.haml
@@ -1,12 +1,14 @@
- redirect_params = { redirect: @redirect } if @redirect
+- accept_term_link = accept_term_path(@term, redirect_params)
.card-body.rendered-terms{ data: { qa_selector: 'terms_content' } }
= markdown_field(@term, :terms)
- if current_user
+ = render_if_exists 'devise/shared/form_phone_verification', accept_term_link: accept_term_link, inline: true
.card-footer.footer-block.clearfix
- if can?(current_user, :accept_terms, @term)
.float-right
- = button_to accept_term_path(@term, redirect_params), class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do
+ = button_to accept_term_link, class: 'gl-button btn btn-confirm gl-ml-3', data: { qa_selector: 'accept_terms_button' } do
= _('Accept terms')
- else
.float-right
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index f326ae0dec8..955674b52a4 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -83,8 +83,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :tags:
- - :exclude_from_gitlab_com
+ :tags: []
- :name: chaos:chaos_db_spin
:worker_name: Chaos::DbSpinWorker
:feature_category: :not_owned
@@ -93,8 +92,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :tags:
- - :exclude_from_gitlab_com
+ :tags: []
- :name: chaos:chaos_kill
:worker_name: Chaos::KillWorker
:feature_category: :not_owned
@@ -103,8 +101,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :tags:
- - :exclude_from_gitlab_com
+ :tags: []
- :name: chaos:chaos_leak_mem
:worker_name: Chaos::LeakMemWorker
:feature_category: :not_owned
@@ -113,8 +110,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :tags:
- - :exclude_from_gitlab_com
+ :tags: []
- :name: chaos:chaos_sleep
:worker_name: Chaos::SleepWorker
:feature_category: :not_owned
@@ -123,8 +119,7 @@
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :tags:
- - :exclude_from_gitlab_com
+ :tags: []
- :name: container_repository:cleanup_container_repository
:worker_name: CleanupContainerRepositoryWorker
:feature_category: :container_registry
@@ -142,8 +137,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: container_repository:delete_container_repository
:worker_name: DeleteContainerRepositoryWorker
:feature_category: :container_registry
@@ -170,8 +164,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:authorized_project_update_periodic_recalculate
:worker_name: AuthorizedProjectUpdate::PeriodicRecalculateWorker
:feature_category: :source_code_management
@@ -207,8 +200,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:ci_platform_metrics_update_cron
:worker_name: CiPlatformMetricsUpdateCronWorker
:feature_category: :continuous_integration
@@ -226,8 +218,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:container_expiration_policy
:worker_name: ContainerExpirationPolicyWorker
:feature_category: :container_registry
@@ -245,8 +236,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:database_drop_detached_partitions
:worker_name: Database::DropDetachedPartitionsWorker
:feature_category: :database
@@ -345,8 +335,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:metrics_dashboard_schedule_annotations_prune
:worker_name: Metrics::Dashboard::ScheduleAnnotationsPruneWorker
:feature_category: :metrics
@@ -364,8 +353,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:namespaces_prune_aggregation_schedules
:worker_name: Namespaces::PruneAggregationSchedulesWorker
:feature_category: :source_code_management
@@ -383,8 +371,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:pages_domain_removal_cron
:worker_name: PagesDomainRemovalCronWorker
:feature_category: :pages
@@ -429,8 +416,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:personal_access_tokens_expiring
:worker_name: PersonalAccessTokens::ExpiringWorker
:feature_category: :authentication_and_authorization
@@ -466,8 +452,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:remove_expired_group_links
:worker_name: RemoveExpiredGroupLinksWorker
:feature_category: :authentication_and_authorization
@@ -494,8 +479,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:remove_unreferenced_lfs_objects
:worker_name: RemoveUnreferencedLfsObjectsWorker
:feature_category: :git_lfs
@@ -540,8 +524,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:schedule_migrate_external_diffs
:worker_name: ScheduleMigrateExternalDiffsWorker
:feature_category: :code_review
@@ -559,8 +542,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:ssh_keys_expiring_soon_notification
:worker_name: SshKeys::ExpiringSoonNotificationWorker
:feature_category: :compliance_management
@@ -569,8 +551,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:stuck_ci_jobs
:worker_name: StuckCiJobsWorker
:feature_category: :continuous_integration
@@ -624,8 +605,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:users_create_statistics
:worker_name: Users::CreateStatisticsWorker
:feature_category: :users
@@ -643,8 +623,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: cronjob:x509_issuer_crl_check
:worker_name: X509IssuerCrlCheckWorker
:feature_category: :source_code_management
@@ -671,25 +650,6 @@
:resource_boundary: :unknown
:weight: 3
:idempotent:
- :tags:
- - :exclude_from_kubernetes
-- :name: deployment:deployments_finished
- :worker_name: Deployments::FinishedWorker
- :feature_category: :continuous_delivery
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :cpu
- :weight: 3
- :idempotent:
- :tags: []
-- :name: deployment:deployments_forward_deployment
- :worker_name: Deployments::ForwardDeploymentWorker
- :feature_category: :continuous_delivery
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :unknown
- :weight: 3
- :idempotent:
:tags: []
- :name: deployment:deployments_hooks
:worker_name: Deployments::HooksWorker
@@ -709,15 +669,6 @@
:weight: 3
:idempotent: true
:tags: []
-- :name: deployment:deployments_success
- :worker_name: Deployments::SuccessWorker
- :feature_category: :continuous_delivery
- :has_external_dependencies:
- :urgency: :low
- :resource_boundary: :cpu
- :weight: 3
- :idempotent:
- :tags: []
- :name: deployment:deployments_update_environment
:worker_name: Deployments::UpdateEnvironmentWorker
:feature_category: :continuous_delivery
@@ -924,8 +875,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: github_importer:github_import_import_pull_request_review
:worker_name: Gitlab::GithubImport::ImportPullRequestReviewWorker
:feature_category: :importers
@@ -934,8 +884,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: github_importer:github_import_refresh_import_jid
:worker_name: Gitlab::GithubImport::RefreshImportJidWorker
:feature_category: :importers
@@ -1007,8 +956,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: github_importer:github_import_stage_import_pull_requests_reviews
:worker_name: Gitlab::GithubImport::Stage::ImportPullRequestsReviewsWorker
:feature_category: :importers
@@ -1017,8 +965,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: github_importer:github_import_stage_import_repository
:worker_name: Gitlab::GithubImport::Stage::ImportRepositoryWorker
:feature_category: :importers
@@ -1037,7 +984,6 @@
:weight: 1
:idempotent:
:tags:
- - :exclude_from_gitlab_com
- :needs_own_queue
- :name: hashed_storage:hashed_storage_project_migrate
:worker_name: HashedStorage::ProjectMigrateWorker
@@ -1048,7 +994,6 @@
:weight: 1
:idempotent:
:tags:
- - :exclude_from_gitlab_com
- :needs_own_queue
- :name: hashed_storage:hashed_storage_project_rollback
:worker_name: HashedStorage::ProjectRollbackWorker
@@ -1059,7 +1004,6 @@
:weight: 1
:idempotent:
:tags:
- - :exclude_from_gitlab_com
- :needs_own_queue
- :name: hashed_storage:hashed_storage_rollbacker
:worker_name: HashedStorage::RollbackerWorker
@@ -1070,7 +1014,6 @@
:weight: 1
:idempotent:
:tags:
- - :exclude_from_gitlab_com
- :needs_own_queue
- :name: incident_management:clusters_applications_check_prometheus_health
:worker_name: Clusters::Applications::CheckPrometheusHealthWorker
@@ -1089,8 +1032,7 @@
:resource_boundary: :cpu
:weight: 2
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: incident_management:incident_management_pager_duty_process_incident
:worker_name: IncidentManagement::PagerDuty::ProcessIncidentWorker
:feature_category: :incident_management
@@ -1144,8 +1086,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: jira_connect:jira_connect_sync_deployments
:worker_name: JiraConnect::SyncDeploymentsWorker
:feature_category: :integrations
@@ -1154,8 +1095,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: jira_connect:jira_connect_sync_feature_flags
:worker_name: JiraConnect::SyncFeatureFlagsWorker
:feature_category: :integrations
@@ -1164,8 +1104,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: jira_connect:jira_connect_sync_merge_request
:worker_name: JiraConnect::SyncMergeRequestWorker
:feature_category: :integrations
@@ -1183,8 +1122,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: jira_importer:jira_import_advance_stage
:worker_name: Gitlab::JiraImport::AdvanceStageWorker
:feature_category: :importers
@@ -1346,8 +1284,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: package_repositories:packages_go_sync_packages
:worker_name: Packages::Go::SyncPackagesWorker
:feature_category: :package_registry
@@ -1356,8 +1293,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: package_repositories:packages_helm_extraction
:worker_name: Packages::Helm::ExtractionWorker
:feature_category: :package_registry
@@ -1375,8 +1311,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: package_repositories:packages_nuget_extraction
:worker_name: Packages::Nuget::ExtractionWorker
:feature_category: :package_registry
@@ -1394,8 +1329,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: pipeline_background:archive_trace
:worker_name: ArchiveTraceWorker
:feature_category: :continuous_integration
@@ -1449,8 +1383,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: pipeline_background:ci_pipeline_success_unlock_artifacts
:worker_name: Ci::PipelineSuccessUnlockArtifactsWorker
:feature_category: :continuous_integration
@@ -1477,8 +1410,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: pipeline_cache:expire_job_cache
:worker_name: ExpireJobCacheWorker
:feature_category: :continuous_integration
@@ -1486,7 +1418,7 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 3
- :idempotent: true
+ :idempotent:
:tags: []
- :name: pipeline_cache:expire_pipeline_cache
:worker_name: ExpirePipelineCacheWorker
@@ -1497,6 +1429,15 @@
:weight: 3
:idempotent:
:tags: []
+- :name: pipeline_creation:ci_external_pull_requests_create_pipeline
+ :worker_name: Ci::ExternalPullRequests::CreatePipelineWorker
+ :feature_category: :pipeline_authoring
+ :has_external_dependencies:
+ :urgency: :high
+ :resource_boundary: :cpu
+ :weight: 4
+ :idempotent:
+ :tags: []
- :name: pipeline_creation:create_pipeline
:worker_name: CreatePipelineWorker
:feature_category: :continuous_integration
@@ -1541,8 +1482,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: pipeline_default:ci_merge_requests_add_todo_when_build_fails
:worker_name: Ci::MergeRequests::AddTodoWhenBuildFailsWorker
:feature_category: :continuous_integration
@@ -1551,8 +1491,7 @@
:resource_boundary: :unknown
:weight: 3
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: pipeline_default:ci_pipeline_bridge_status
:worker_name: Ci::PipelineBridgeStatusWorker
:feature_category: :continuous_integration
@@ -1742,6 +1681,15 @@
:weight: 1
:idempotent:
:tags: []
+- :name: todos_destroyer:todos_destroyer_destroyed_designs
+ :worker_name: TodosDestroyer::DestroyedDesignsWorker
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: todos_destroyer:todos_destroyer_destroyed_issuable
:worker_name: TodosDestroyer::DestroyedIssuableWorker
:feature_category: :issue_tracking
@@ -1750,8 +1698,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: todos_destroyer:todos_destroyer_entity_leave
:worker_name: TodosDestroyer::EntityLeaveWorker
:feature_category: :issue_tracking
@@ -1823,8 +1770,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: approve_blocked_pending_approval_users
:worker_name: ApproveBlockedPendingApprovalUsersWorker
:feature_category: :users
@@ -1833,8 +1779,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: authorized_keys
:worker_name: AuthorizedKeysWorker
:feature_category: :source_code_management
@@ -1870,8 +1815,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: bulk_imports_entity
:worker_name: BulkImports::EntityWorker
:feature_category: :importers
@@ -1880,8 +1824,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: bulk_imports_export_request
:worker_name: BulkImports::ExportRequestWorker
:feature_category: :importers
@@ -1899,8 +1842,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: bulk_imports_relation_export
:worker_name: BulkImports::RelationExportWorker
:feature_category: :importers
@@ -1909,8 +1851,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: chat_notification
:worker_name: ChatNotificationWorker
:feature_category: :chatops
@@ -1928,8 +1869,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: create_commit_signature
:worker_name: CreateCommitSignatureWorker
:feature_category: :source_code_management
@@ -2001,8 +1941,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: design_management_new_version
:worker_name: DesignManagement::NewVersionWorker
:feature_category: :design_management
@@ -2020,8 +1959,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: detect_repository_languages
:worker_name: DetectRepositoryLanguagesWorker
:feature_category: :source_code_management
@@ -2039,8 +1977,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: disallow_two_factor_for_subgroups
:worker_name: DisallowTwoFactorForSubgroupsWorker
:feature_category: :subgroups
@@ -2049,8 +1986,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: email_receiver
:worker_name: EmailReceiverWorker
:feature_category: :issue_tracking
@@ -2070,6 +2006,15 @@
:weight: 2
:idempotent:
:tags: []
+- :name: environments_auto_stop
+ :worker_name: Environments::AutoStopWorker
+ :feature_category: :continuous_delivery
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :unknown
+ :weight: 1
+ :idempotent: true
+ :tags: []
- :name: environments_canary_ingress_update
:worker_name: Environments::CanaryIngress::UpdateWorker
:feature_category: :continuous_delivery
@@ -2078,8 +2023,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: error_tracking_issue_link
:worker_name: ErrorTrackingIssueLinkWorker
:feature_category: :error_tracking
@@ -2097,8 +2041,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: expire_build_instance_artifacts
:worker_name: ExpireBuildInstanceArtifactsWorker
:feature_category: :continuous_integration
@@ -2143,8 +2086,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: github_import_advance_stage
:worker_name: Gitlab::GithubImport::AdvanceStageWorker
:feature_category: :importers
@@ -2162,8 +2104,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: gitlab_shell
:worker_name: GitlabShellWorker
:feature_category: :source_code_management
@@ -2181,9 +2122,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :requires_disk_io
- - :exclude_from_kubernetes
+ :tags: []
- :name: group_export
:worker_name: GroupExportWorker
:feature_category: :importers
@@ -2273,8 +2212,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: mailers
:worker_name: ActionMailer::MailDeliveryJob
:feature_category: :issue_tracking
@@ -2301,8 +2239,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: merge_request_mergeability_check
:worker_name: MergeRequestMergeabilityCheckWorker
:feature_category: :code_review
@@ -2356,8 +2293,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: migrate_external_diffs
:worker_name: MigrateExternalDiffsWorker
:feature_category: :code_review
@@ -2384,8 +2320,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: namespaces_onboarding_pipeline_created
:worker_name: Namespaces::OnboardingPipelineCreatedWorker
:feature_category: :subgroups
@@ -2394,8 +2329,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: namespaces_onboarding_progress
:worker_name: Namespaces::OnboardingProgressWorker
:feature_category: :product_analytics
@@ -2404,8 +2338,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: namespaces_onboarding_user_added
:worker_name: Namespaces::OnboardingUserAddedWorker
:feature_category: :users
@@ -2414,8 +2347,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: new_issue
:worker_name: NewIssueWorker
:feature_category: :issue_tracking
@@ -2451,8 +2383,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: pages
:worker_name: PagesWorker
:feature_category: :pages
@@ -2461,9 +2392,7 @@
:resource_boundary: :cpu
:weight: 1
:idempotent:
- :tags:
- - :requires_disk_io
- - :exclude_from_kubernetes
+ :tags: []
- :name: pages_domain_ssl_renewal
:worker_name: PagesDomainSslRenewalWorker
:feature_category: :pages
@@ -2472,9 +2401,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :requires_disk_io
- - :exclude_from_kubernetes
+ :tags: []
- :name: pages_domain_verification
:worker_name: PagesDomainVerificationWorker
:feature_category: :pages
@@ -2483,9 +2410,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :requires_disk_io
- - :exclude_from_kubernetes
+ :tags: []
- :name: pages_remove
:worker_name: PagesRemoveWorker
:feature_category: :pages
@@ -2494,8 +2419,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: pages_transfer
:worker_name: PagesTransferWorker
:feature_category: :pages
@@ -2504,8 +2428,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: pages_update_configuration
:worker_name: PagesUpdateConfigurationWorker
:feature_category: :pages
@@ -2514,8 +2437,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: phabricator_import_import_tasks
:worker_name: Gitlab::PhabricatorImport::ImportTasksWorker
:feature_category: :importers
@@ -2532,7 +2454,7 @@
:urgency: :high
:resource_boundary: :cpu
:weight: 5
- :idempotent:
+ :idempotent: true
:tags: []
- :name: process_commit
:worker_name: ProcessCommitWorker
@@ -2569,9 +2491,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :requires_disk_io
- - :exclude_from_kubernetes
+ :tags: []
- :name: project_export
:worker_name: ProjectExportWorker
:feature_category: :importers
@@ -2598,8 +2518,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: projects_post_creation
:worker_name: Projects::PostCreationWorker
:feature_category: :source_code_management
@@ -2608,8 +2527,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: projects_schedule_bulk_repository_shard_moves
:worker_name: Projects::ScheduleBulkRepositoryShardMovesWorker
:feature_category: :gitaly
@@ -2654,8 +2572,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: propagate_integration_inherit
:worker_name: PropagateIntegrationInheritWorker
:feature_category: :integrations
@@ -2664,8 +2581,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: propagate_integration_inherit_descendant
:worker_name: PropagateIntegrationInheritDescendantWorker
:feature_category: :integrations
@@ -2674,8 +2590,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: propagate_integration_project
:worker_name: PropagateIntegrationProjectWorker
:feature_category: :integrations
@@ -2684,8 +2599,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: propagate_service_template
:worker_name: PropagateServiceTemplateWorker
:feature_category: :integrations
@@ -2721,8 +2635,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: remote_mirror_notification
:worker_name: RemoteMirrorNotificationWorker
:feature_category: :source_code_management
@@ -2799,7 +2712,7 @@
:worker_name: ServiceDeskEmailReceiverWorker
:feature_category: :service_desk
:has_external_dependencies:
- :urgency: :low
+ :urgency: :high
:resource_boundary: :unknown
:weight: 1
:idempotent:
@@ -2894,8 +2807,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent: true
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: web_hooks_log_execution
:worker_name: WebHooks::LogExecutionWorker
:feature_category: :integrations
@@ -2913,8 +2825,7 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
- :tags:
- - :exclude_from_kubernetes
+ :tags: []
- :name: x509_certificate_revoke
:worker_name: X509CertificateRevokeWorker
:feature_category: :source_code_management
diff --git a/app/workers/analytics/usage_trends/count_job_trigger_worker.rb b/app/workers/analytics/usage_trends/count_job_trigger_worker.rb
index 41ef75ac20a..dae576a6521 100644
--- a/app/workers/analytics/usage_trends/count_job_trigger_worker.rb
+++ b/app/workers/analytics/usage_trends/count_job_trigger_worker.rb
@@ -13,7 +13,6 @@ module Analytics
DEFAULT_DELAY = 3.minutes.freeze
feature_category :devops_reports
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/analytics/usage_trends/counter_job_worker.rb b/app/workers/analytics/usage_trends/counter_job_worker.rb
index bfb8a435939..b3a8f7dd3c2 100644
--- a/app/workers/analytics/usage_trends/counter_job_worker.rb
+++ b/app/workers/analytics/usage_trends/counter_job_worker.rb
@@ -12,7 +12,6 @@ module Analytics
feature_category :devops_reports
urgency :low
- tags :exclude_from_kubernetes
idempotent!
diff --git a/app/workers/approve_blocked_pending_approval_users_worker.rb b/app/workers/approve_blocked_pending_approval_users_worker.rb
index fdf1bd99558..661ec87c1dd 100644
--- a/app/workers/approve_blocked_pending_approval_users_worker.rb
+++ b/app/workers/approve_blocked_pending_approval_users_worker.rb
@@ -10,7 +10,6 @@ class ApproveBlockedPendingApprovalUsersWorker
idempotent!
feature_category :users
- tags :exclude_from_kubernetes
def perform(current_user_id)
current_user = User.find(current_user_id)
diff --git a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
index ef5dcc1cb99..48e3d0837c7 100644
--- a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
+++ b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb
@@ -14,19 +14,12 @@ module AuthorizedProjectUpdate
deduplicate :until_executing, including_scheduled: true
def perform(user_id)
- if Feature.enabled?(:user_refresh_from_replica_worker_uses_replica_db)
- use_replica_if_available do
- user = User.find_by_id(user_id)
-
- if user && project_authorizations_needs_refresh?(user)
- enqueue_project_authorizations_refresh(user)
- end
- end
- else
+ use_replica_if_available do
user = User.find_by_id(user_id)
- return unless user
- user.refresh_authorized_projects(source: self.class.name)
+ if user && project_authorizations_needs_refresh?(user)
+ enqueue_project_authorizations_refresh(user)
+ end
end
end
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
index ef58258d998..b771ab4d4e7 100644
--- a/app/workers/background_migration_worker.rb
+++ b/app/workers/background_migration_worker.rb
@@ -3,6 +3,8 @@
class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
+ MAX_LEASE_ATTEMPTS = 5
+
data_consistency :always
sidekiq_options retry: 3
@@ -30,10 +32,11 @@ class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
# lease_attempts - The number of times we will try to obtain an exclusive
# lease on the class before giving up. See MR for more discussion.
# https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45298#note_434304956
- def perform(class_name, arguments = [], lease_attempts = 5)
+ def perform(class_name, arguments = [], lease_attempts = MAX_LEASE_ATTEMPTS)
with_context(caller_id: class_name.to_s) do
+ retried = lease_attempts != MAX_LEASE_ATTEMPTS
attempts_left = lease_attempts - 1
- should_perform, ttl = perform_and_ttl(class_name, attempts_left)
+ should_perform, ttl = perform_and_ttl(class_name, attempts_left, retried)
break if should_perform.nil?
@@ -50,13 +53,13 @@ class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
end
end
- def perform_and_ttl(class_name, attempts_left)
+ def perform_and_ttl(class_name, attempts_left, retried)
# In test environments `perform_in` will run right away. This can then
# lead to stack level errors in the above `#perform`. To work around this
# we'll just perform the migration right away in the test environment.
return [true, nil] if always_perform?
- lease = lease_for(class_name)
+ lease = lease_for(class_name, retried)
lease_obtained = !!lease.try_obtain
healthy_db = healthy_database?
perform = lease_obtained && healthy_db
@@ -82,13 +85,17 @@ class BackgroundMigrationWorker # rubocop:disable Scalability/IdempotentWorker
[perform, lease.ttl]
end
- def lease_for(class_name)
+ def lease_for(class_name, retried)
Gitlab::ExclusiveLease
- .new(lease_key_for(class_name), timeout: self.class.minimum_interval)
+ .new(lease_key_for(class_name, retried), timeout: self.class.minimum_interval)
end
- def lease_key_for(class_name)
- "#{self.class.name}:#{class_name}"
+ def lease_key_for(class_name, retried)
+ key = "#{self.class.name}:#{class_name}"
+ # We use a different exclusive lock key for retried jobs to allow them running concurrently with the scheduled jobs.
+ # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68763 for more information.
+ key += ":retried" if retried
+ key
end
def always_perform?
diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb
index 3f3d61a8df1..ce39ac946a9 100644
--- a/app/workers/build_success_worker.rb
+++ b/app/workers/build_success_worker.rb
@@ -21,8 +21,6 @@ class BuildSuccessWorker # rubocop:disable Scalability/IdempotentWorker
private
- ##
- # TODO: This should be processed in DeploymentSuccessWorker once we started storing `action` value in `deployments` records
def stop_environment(build)
build.persisted_environment.fire_state_event(:stop)
end
diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb
index d7f0b752a34..fa255d064cc 100644
--- a/app/workers/bulk_import_worker.rb
+++ b/app/workers/bulk_import_worker.rb
@@ -6,7 +6,6 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
data_consistency :always
feature_category :importers
- tags :exclude_from_kubernetes
sidekiq_options retry: false, dead: false
@@ -25,9 +24,9 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
@bulk_import.start! if @bulk_import.created?
created_entities.first(next_batch_size).each do |entity|
- create_pipeline_tracker_for(entity)
+ entity.create_pipeline_trackers!
- BulkImports::ExportRequestWorker.perform_async(entity.id)
+ BulkImports::ExportRequestWorker.perform_async(entity.id) if entity.group_entity?
BulkImports::EntityWorker.perform_async(entity.id)
entity.start!
@@ -76,13 +75,4 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker
def re_enqueue
BulkImportWorker.perform_in(PERFORM_DELAY, @bulk_import.id)
end
-
- def create_pipeline_tracker_for(entity)
- BulkImports::Stage.pipelines.each do |stage, pipeline|
- entity.trackers.create!(
- stage: stage,
- pipeline_name: pipeline
- )
- end
- end
end
diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb
index cc52e349130..5c04cdc96a0 100644
--- a/app/workers/bulk_imports/entity_worker.rb
+++ b/app/workers/bulk_imports/entity_worker.rb
@@ -7,7 +7,6 @@ module BulkImports
data_consistency :always
feature_category :importers
- tags :exclude_from_kubernetes
sidekiq_options retry: false, dead: false
diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb
index 713c6c69213..760a309a381 100644
--- a/app/workers/bulk_imports/pipeline_worker.rb
+++ b/app/workers/bulk_imports/pipeline_worker.rb
@@ -9,7 +9,6 @@ module BulkImports
NDJSON_PIPELINE_PERFORM_DELAY = 1.minute
feature_category :importers
- tags :exclude_from_kubernetes
sidekiq_options retry: false, dead: false
diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb
index 416dad5b3ae..9324b79cc75 100644
--- a/app/workers/bulk_imports/relation_export_worker.rb
+++ b/app/workers/bulk_imports/relation_export_worker.rb
@@ -10,7 +10,6 @@ module BulkImports
idempotent!
loggable_arguments 2, 3
feature_category :importers
- tags :exclude_from_kubernetes
sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION
def perform(user_id, portable_id, portable_class, relation)
diff --git a/app/workers/ci/delete_objects_worker.rb b/app/workers/ci/delete_objects_worker.rb
index d31d248597b..cbcad3e8838 100644
--- a/app/workers/ci/delete_objects_worker.rb
+++ b/app/workers/ci/delete_objects_worker.rb
@@ -10,7 +10,6 @@ module Ci
include LimitedCapacity::Worker
feature_category :continuous_integration
- tags :exclude_from_kubernetes
idempotent!
def perform_work(*args)
diff --git a/app/workers/ci/drop_pipeline_worker.rb b/app/workers/ci/drop_pipeline_worker.rb
index f3672dba3fe..edb97c3cac5 100644
--- a/app/workers/ci/drop_pipeline_worker.rb
+++ b/app/workers/ci/drop_pipeline_worker.rb
@@ -9,8 +9,6 @@ module Ci
sidekiq_options retry: 3
include PipelineQueue
- tags :exclude_from_kubernetes
-
idempotent!
def perform(pipeline_id, failure_reason)
diff --git a/app/workers/ci/external_pull_requests/create_pipeline_worker.rb b/app/workers/ci/external_pull_requests/create_pipeline_worker.rb
new file mode 100644
index 00000000000..211ea1f2990
--- /dev/null
+++ b/app/workers/ci/external_pull_requests/create_pipeline_worker.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Ci
+ module ExternalPullRequests
+ class CreatePipelineWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ data_consistency :always
+ queue_namespace :pipeline_creation
+ feature_category :pipeline_authoring
+ urgency :high
+ worker_resource_boundary :cpu
+
+ def perform(project_id, user_id, external_pull_request_id)
+ user = User.find_by_id(user_id)
+ return unless user
+
+ project = Project.find_by_id(project_id)
+ return unless project
+
+ external_pull_request = project.external_pull_requests.find_by_id(external_pull_request_id)
+ return unless external_pull_request
+
+ ::Ci::CreatePipelineService
+ .new(project, user, execute_params(external_pull_request))
+ .execute(:external_pull_request_event, external_pull_request: external_pull_request)
+ end
+
+ private
+
+ def execute_params(pull_request)
+ {
+ ref: pull_request.source_ref,
+ source_sha: pull_request.source_sha,
+ target_sha: pull_request.target_sha
+ }
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb b/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb
index af042dc1e64..98bb259db0a 100644
--- a/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb
+++ b/app/workers/ci/merge_requests/add_todo_when_build_fails_worker.rb
@@ -10,7 +10,6 @@ module Ci
include PipelineQueue
urgency :low
- tags :exclude_from_kubernetes
idempotent!
def perform(job_id)
diff --git a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb
index 06bc100c66a..bb0a81a0a17 100644
--- a/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb
+++ b/app/workers/ci/pipeline_artifacts/create_quality_report_worker.rb
@@ -11,7 +11,6 @@ module Ci
queue_namespace :pipeline_background
feature_category :code_testing
- tags :exclude_from_kubernetes
idempotent!
diff --git a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
index e4dc293353b..2af07cf6f93 100644
--- a/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
+++ b/app/workers/ci/pipeline_artifacts/expire_artifacts_worker.rb
@@ -15,7 +15,6 @@ module Ci
deduplicate :until_executed, including_scheduled: true
idempotent!
feature_category :continuous_integration
- tags :exclude_from_kubernetes
def perform
service = ::Ci::PipelineArtifacts::DestroyAllExpiredService.new
diff --git a/app/workers/ci/schedule_delete_objects_cron_worker.rb b/app/workers/ci/schedule_delete_objects_cron_worker.rb
index 06bf83ae0a7..55b23bbab62 100644
--- a/app/workers/ci/schedule_delete_objects_cron_worker.rb
+++ b/app/workers/ci/schedule_delete_objects_cron_worker.rb
@@ -12,7 +12,6 @@ module Ci
# rubocop:enable Scalability/CronWorkerContext
feature_category :continuous_integration
- tags :exclude_from_kubernetes
idempotent!
def perform(*args)
diff --git a/app/workers/ci/test_failure_history_worker.rb b/app/workers/ci/test_failure_history_worker.rb
index b67797edf0b..e79ca50c8ce 100644
--- a/app/workers/ci/test_failure_history_worker.rb
+++ b/app/workers/ci/test_failure_history_worker.rb
@@ -9,8 +9,6 @@ module Ci
sidekiq_options retry: 3
include PipelineBackgroundQueue
- tags :exclude_from_kubernetes
-
idempotent!
def perform(pipeline_id)
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 6cc6c30c5e9..3399a4f9b57 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -58,10 +58,7 @@ module ApplicationWorker
Gitlab::SidekiqConfig::WorkerRouter.queue_name_from_worker_name(self)
end
- override :validate_worker_attributes!
def validate_worker_attributes!
- super
-
# Since the delayed data_consistency will use sidekiq built in retry mechanism, it is required that this mechanism
# is not disabled.
if retry_disabled? && get_data_consistency == :delayed
@@ -81,6 +78,13 @@ module ApplicationWorker
end
end
+ override :data_consistency
+ def data_consistency(data_consistency, feature_flag: nil)
+ super
+
+ validate_worker_attributes!
+ end
+
def perform_async(*args)
# Worker execution for workers with data_consistency set to :delayed or :sticky
# will be delayed to give replication enough time to complete
diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb
index 2ccd55157c6..a9c557f0175 100644
--- a/app/workers/concerns/chaos_queue.rb
+++ b/app/workers/concerns/chaos_queue.rb
@@ -6,6 +6,5 @@ module ChaosQueue
included do
queue_namespace :chaos
feature_category_not_owned!
- tags :exclude_from_gitlab_com
end
end
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 806fce38636..eebea30655c 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -92,17 +92,6 @@ module WorkerAttributes
set_class_attribute(:data_consistency_feature_flag, feature_flag) if feature_flag
set_class_attribute(:data_consistency, data_consistency)
-
- validate_worker_attributes!
- end
-
- def validate_worker_attributes!
- # Since the deduplication should always take into account the latest binary replication pointer into account,
- # not the first one, the deduplication will not work with sticky or delayed.
- # Follow up issue to improve this: https://gitlab.com/gitlab-org/gitlab/-/issues/325291
- if idempotent? && utilizes_load_balancing_capabilities?
- raise ArgumentError, "Class can't be marked as idempotent if data_consistency is not set to :always"
- end
end
# If data_consistency is not set to :always, worker will try to utilize load balancing capabilities and use the replica
@@ -147,8 +136,6 @@ module WorkerAttributes
def idempotent!
set_class_attribute(:idempotent, true)
-
- validate_worker_attributes!
end
def idempotent?
diff --git a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
index de9bb4d5a93..433ed5e0ea4 100644
--- a/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
+++ b/app/workers/container_expiration_policies/cleanup_container_repository_worker.rb
@@ -12,7 +12,6 @@ module ContainerExpirationPolicies
queue_namespace :container_repository
feature_category :container_registry
- tags :exclude_from_kubernetes
urgency :low
worker_resource_boundary :unknown
idempotent!
diff --git a/app/workers/database/batched_background_migration_worker.rb b/app/workers/database/batched_background_migration_worker.rb
index 0750ff1acaf..fda539b372d 100644
--- a/app/workers/database/batched_background_migration_worker.rb
+++ b/app/workers/database/batched_background_migration_worker.rb
@@ -9,7 +9,6 @@ module Database
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :database
- tags :exclude_from_kubernetes
idempotent!
LEASE_TIMEOUT_MULTIPLIER = 3
diff --git a/app/workers/database/partition_management_worker.rb b/app/workers/database/partition_management_worker.rb
index a203c76558a..5a1f139dc29 100644
--- a/app/workers/database/partition_management_worker.rb
+++ b/app/workers/database/partition_management_worker.rb
@@ -12,7 +12,7 @@ module Database
idempotent!
def perform
- Gitlab::Database::Partitioning::PartitionManager.new.sync_partitions
+ Gitlab::Database::Partitioning.sync_partitions
ensure
Gitlab::Database::Partitioning::PartitionMonitoring.new.report_metrics
end
diff --git a/app/workers/deployments/drop_older_deployments_worker.rb b/app/workers/deployments/drop_older_deployments_worker.rb
index 979f683cfb3..c464febd119 100644
--- a/app/workers/deployments/drop_older_deployments_worker.rb
+++ b/app/workers/deployments/drop_older_deployments_worker.rb
@@ -10,7 +10,6 @@ module Deployments
queue_namespace :deployment
feature_category :continuous_delivery
- tags :exclude_from_kubernetes
def perform(deployment_id)
Deployments::OlderDeploymentsDropService.new(deployment_id).execute
diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb
deleted file mode 100644
index 25121656408..00000000000
--- a/app/workers/deployments/finished_worker.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-# This worker is deprecated and will be removed in 14.0
-# See: https://gitlab.com/gitlab-org/gitlab/-/issues/266381
-module Deployments
- class FinishedWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- queue_namespace :deployment
- feature_category :continuous_delivery
- worker_resource_boundary :cpu
-
- def perform(deployment_id)
- if (deploy = Deployment.find_by_id(deployment_id))
- LinkMergeRequestsService.new(deploy).execute
- deploy.execute_hooks(Time.current)
- end
- end
- end
-end
diff --git a/app/workers/deployments/forward_deployment_worker.rb b/app/workers/deployments/forward_deployment_worker.rb
deleted file mode 100644
index 7f5eb13b88d..00000000000
--- a/app/workers/deployments/forward_deployment_worker.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-# This worker is deprecated and will be removed in 14.0
-# See: https://gitlab.com/gitlab-org/gitlab/-/issues/266381
-module Deployments
- class ForwardDeploymentWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- queue_namespace :deployment
- feature_category :continuous_delivery
-
- def perform(deployment_id)
- Deployments::OlderDeploymentsDropService.new(deployment_id).execute
- end
- end
-end
diff --git a/app/workers/deployments/hooks_worker.rb b/app/workers/deployments/hooks_worker.rb
index d23a440ed36..31c57e5c001 100644
--- a/app/workers/deployments/hooks_worker.rb
+++ b/app/workers/deployments/hooks_worker.rb
@@ -4,7 +4,7 @@ module Deployments
class HooksWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- data_consistency :delayed, feature_flag: :load_balancing_for_deployments_hooks_worker
+ data_consistency :delayed
queue_namespace :deployment
feature_category :continuous_delivery
diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb
deleted file mode 100644
index 401c2d7600c..00000000000
--- a/app/workers/deployments/success_worker.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-# This worker is deprecated and will be removed in 14.0
-# See: https://gitlab.com/gitlab-org/gitlab/-/issues/266381
-module Deployments
- class SuccessWorker # rubocop:disable Scalability/IdempotentWorker
- include ApplicationWorker
-
- data_consistency :always
-
- sidekiq_options retry: 3
-
- queue_namespace :deployment
- feature_category :continuous_delivery
- worker_resource_boundary :cpu
-
- def perform(deployment_id)
- Deployment.find_by_id(deployment_id).try do |deployment|
- break unless deployment.success?
-
- Deployments::UpdateEnvironmentService.new(deployment).execute
- end
- end
- end
-end
diff --git a/app/workers/design_management/copy_design_collection_worker.rb b/app/workers/design_management/copy_design_collection_worker.rb
index a498eed173c..8b265979afa 100644
--- a/app/workers/design_management/copy_design_collection_worker.rb
+++ b/app/workers/design_management/copy_design_collection_worker.rb
@@ -9,7 +9,6 @@ module DesignManagement
sidekiq_options retry: 3
feature_category :design_management
- tags :exclude_from_kubernetes
idempotent!
urgency :low
diff --git a/app/workers/destroy_pages_deployments_worker.rb b/app/workers/destroy_pages_deployments_worker.rb
index 36424f7473e..7fa73648dd2 100644
--- a/app/workers/destroy_pages_deployments_worker.rb
+++ b/app/workers/destroy_pages_deployments_worker.rb
@@ -10,7 +10,6 @@ class DestroyPagesDeploymentsWorker
loggable_arguments 0, 1
sidekiq_options retry: 3
feature_category :pages
- tags :exclude_from_kubernetes
def perform(project_id, last_deployment_id = nil)
project = Project.find_by_id(project_id)
diff --git a/app/workers/disallow_two_factor_for_group_worker.rb b/app/workers/disallow_two_factor_for_group_worker.rb
index 4f5ef69a730..5b958f9f31f 100644
--- a/app/workers/disallow_two_factor_for_group_worker.rb
+++ b/app/workers/disallow_two_factor_for_group_worker.rb
@@ -9,7 +9,6 @@ class DisallowTwoFactorForGroupWorker
include ExceptionBacktrace
feature_category :subgroups
- tags :exclude_from_kubernetes
idempotent!
def perform(group_id)
diff --git a/app/workers/disallow_two_factor_for_subgroups_worker.rb b/app/workers/disallow_two_factor_for_subgroups_worker.rb
index d3528b0674b..500c13deed2 100644
--- a/app/workers/disallow_two_factor_for_subgroups_worker.rb
+++ b/app/workers/disallow_two_factor_for_subgroups_worker.rb
@@ -11,7 +11,6 @@ class DisallowTwoFactorForSubgroupsWorker
INTERVAL = 2.seconds.to_i
feature_category :subgroups
- tags :exclude_from_kubernetes
idempotent!
def perform(group_id)
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index 1514897b2e4..51211834e06 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -11,7 +11,7 @@ class EmailReceiverWorker # rubocop:disable Scalability/IdempotentWorker
urgency :high
weight 2
- # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1087#jobs-written-to-redis-without-passing-through-the-application
+ # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1263
tags :needs_own_queue
attr_accessor :raw
diff --git a/app/workers/environments/auto_stop_worker.rb b/app/workers/environments/auto_stop_worker.rb
new file mode 100644
index 00000000000..672a4f4121e
--- /dev/null
+++ b/app/workers/environments/auto_stop_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Environments
+ class AutoStopWorker
+ include ApplicationWorker
+
+ data_consistency :always
+ idempotent!
+ feature_category :continuous_delivery
+
+ def perform(environment_id, params = {})
+ Environment.find_by_id(environment_id).try do |environment|
+ user = environment.stop_action&.user
+ environment.stop_with_action!(user)
+ end
+ end
+ end
+end
diff --git a/app/workers/environments/canary_ingress/update_worker.rb b/app/workers/environments/canary_ingress/update_worker.rb
index 591c88cac96..02f24db1469 100644
--- a/app/workers/environments/canary_ingress/update_worker.rb
+++ b/app/workers/environments/canary_ingress/update_worker.rb
@@ -11,7 +11,6 @@ module Environments
idempotent!
worker_has_external_dependencies!
feature_category :continuous_delivery
- tags :exclude_from_kubernetes
def perform(environment_id, params)
Environment.find_by_id(environment_id).try do |environment|
diff --git a/app/workers/experiments/record_conversion_event_worker.rb b/app/workers/experiments/record_conversion_event_worker.rb
index 4c82c114d15..6487f030628 100644
--- a/app/workers/experiments/record_conversion_event_worker.rb
+++ b/app/workers/experiments/record_conversion_event_worker.rb
@@ -9,7 +9,6 @@ module Experiments
sidekiq_options retry: 3
feature_category :users
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb
index cd5ca25f031..401fe1dc1e5 100644
--- a/app/workers/expire_job_cache_worker.rb
+++ b/app/workers/expire_job_cache_worker.rb
@@ -1,20 +1,24 @@
# frozen_string_literal: true
-class ExpireJobCacheWorker
+class ExpireJobCacheWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- data_consistency :always
+ data_consistency :delayed
sidekiq_options retry: 3
include PipelineQueue
queue_namespace :pipeline_cache
urgency :high
- idempotent!
+ # This worker should be idempotent, but we're switching to data_consistency
+ # :sticky and there is an ongoing incompatibility, so it needs to be disabled for
+ # now. The following line can be uncommented and this comment removed once
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/325291 is resolved.
+ # idempotent!
# rubocop: disable CodeReuse/ActiveRecord
def perform(job_id)
- job = CommitStatus.eager_load_pipeline.find_by(id: job_id)
+ job = CommitStatus.preload(:pipeline, :project).find_by(id: job_id)
return unless job
pipeline = job.pipeline
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 64f73d1fba1..07e6939d1c7 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -19,7 +19,7 @@ class ExpirePipelineCacheWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
- pipeline = Ci::Pipeline.eager_load_project.find_by(id: pipeline_id)
+ pipeline = Ci::Pipeline.find_by(id: pipeline_id)
return unless pipeline
Ci::ExpirePipelineCacheService.new.execute(pipeline)
diff --git a/app/workers/flush_counter_increments_worker.rb b/app/workers/flush_counter_increments_worker.rb
index bcb6a4c2bca..c4a3a5283cc 100644
--- a/app/workers/flush_counter_increments_worker.rb
+++ b/app/workers/flush_counter_increments_worker.rb
@@ -13,7 +13,6 @@ class FlushCounterIncrementsWorker
sidekiq_options retry: 3
feature_category_not_owned!
- tags :exclude_from_kubernetes
urgency :low
deduplicate :until_executing, including_scheduled: true
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
index cce179542c7..ab0cb81249b 100644
--- 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
@@ -5,7 +5,6 @@ module Gitlab
class ImportPullRequestMergedByWorker # rubocop:disable Scalability/IdempotentWorker
include ObjectImporter
- tags :exclude_from_kubernetes
worker_resource_boundary :cpu
def representation_class
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
index 8796d6392df..8d5c7b95b10 100644
--- a/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
+++ b/app/workers/gitlab/github_import/import_pull_request_review_worker.rb
@@ -5,7 +5,6 @@ module Gitlab
class ImportPullRequestReviewWorker # rubocop:disable Scalability/IdempotentWorker
include ObjectImporter
- tags :exclude_from_kubernetes
worker_resource_boundary :cpu
def representation_class
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 c33836e20d1..5188bda03e2 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
@@ -12,17 +12,10 @@ module Gitlab
include GithubImport::Queue
include StageMethods
- # The importers to run in this stage. Issues can't be imported earlier
- # on as we also use these to enrich pull requests with assigned labels.
- IMPORTERS = [
- Importer::IssuesImporter,
- Importer::DiffNotesImporter
- ].freeze
-
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
- waiters = IMPORTERS.each_with_object({}) do |klass, hash|
+ waiters = importers(project).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
@@ -30,6 +23,25 @@ module Gitlab
AdvanceStageWorker.perform_async(project.id, waiters, :notes)
end
+
+ # The importers to run in this stage. Issues can't be imported earlier
+ # on as we also use these to enrich pull requests with assigned labels.
+ def importers(project)
+ [
+ Importer::IssuesImporter,
+ diff_notes_importer(project)
+ ]
+ end
+
+ private
+
+ def diff_notes_importer(project)
+ if project.group.present? && Feature.enabled?(:github_importer_single_endpoint_notes_import, project.group, type: :ops, default_enabled: :yaml)
+ Importer::SingleEndpointDiffNotesImporter
+ else
+ Importer::DiffNotesImporter
+ end
+ end
end
end
end
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 0160145ffe2..90a1337169f 100644
--- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb
+++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb
@@ -15,17 +15,31 @@ 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
+ waiters = importers(project).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
AdvanceStageWorker.perform_async(
project.id,
- { waiter.key => waiter.jobs_remaining },
+ waiters,
:lfs_objects
)
end
+
+ def importers(project)
+ if project.group.present? && Feature.enabled?(:github_importer_single_endpoint_notes_import, project.group, type: :ops, default_enabled: :yaml)
+ [
+ Importer::SingleEndpointMergeRequestNotesImporter,
+ Importer::SingleEndpointIssueNotesImporter
+ ]
+ else
+ [
+ Importer::NotesImporter
+ ]
+ end
+ end
end
end
end
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
index 7d83fe288da..8c2d652a689 100644
--- 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
@@ -12,8 +12,6 @@ module Gitlab
include GithubImport::Queue
include StageMethods
- tags :exclude_from_kubernetes
-
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
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
index ea3b89efd22..e10f1170618 100644
--- 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
@@ -12,8 +12,6 @@ module Gitlab
include GithubImport::Queue
include StageMethods
- tags :exclude_from_kubernetes
-
# client - An instance of Gitlab::GithubImport::Client.
# project - An instance of Project.
def import(client, project)
diff --git a/app/workers/gitlab_performance_bar_stats_worker.rb b/app/workers/gitlab_performance_bar_stats_worker.rb
index 4e8bcb9af7b..6d637ad1586 100644
--- a/app/workers/gitlab_performance_bar_stats_worker.rb
+++ b/app/workers/gitlab_performance_bar_stats_worker.rb
@@ -15,7 +15,6 @@ class GitlabPerformanceBarStatsWorker
STATS_KEY_EXPIRE = 30.minutes.to_i
feature_category :metrics
- tags :exclude_from_kubernetes
idempotent!
def perform(lease_uuid)
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
index f44c109f12d..92195d3fe16 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -9,7 +9,6 @@ class GroupDestroyWorker # rubocop:disable Scalability/IdempotentWorker
include ExceptionBacktrace
feature_category :subgroups
- tags :requires_disk_io, :exclude_from_kubernetes
def perform(group_id, user_id)
begin
diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb
index 80e86fd7814..03019ae3131 100644
--- a/app/workers/hashed_storage/migrator_worker.rb
+++ b/app/workers/hashed_storage/migrator_worker.rb
@@ -11,9 +11,8 @@ module HashedStorage
queue_namespace :hashed_storage
feature_category :source_code_management
- # Gitlab::HashedStorage::Migrator#migration_pending? depends on the
- # queue size of this worker.
- tags :exclude_from_gitlab_com, :needs_own_queue
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/340629
+ tags :needs_own_queue
# @param [Integer] start initial ID of the batch
# @param [Integer] finish last ID of the batch
diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb
index edddea55356..bcc80cc2a70 100644
--- a/app/workers/hashed_storage/project_migrate_worker.rb
+++ b/app/workers/hashed_storage/project_migrate_worker.rb
@@ -11,9 +11,8 @@ module HashedStorage
queue_namespace :hashed_storage
loggable_arguments 1
- # Gitlab::HashedStorage::Migrator#migration_pending? depends on the
- # queue size of this worker.
- tags :exclude_from_gitlab_com, :needs_own_queue
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/340629
+ tags :needs_own_queue
attr_reader :project_id
diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb
index c5841dbbb28..07a7ab63718 100644
--- a/app/workers/hashed_storage/project_rollback_worker.rb
+++ b/app/workers/hashed_storage/project_rollback_worker.rb
@@ -11,9 +11,8 @@ module HashedStorage
queue_namespace :hashed_storage
loggable_arguments 1
- # Gitlab::HashedStorage::Migrator#rollback_pending? depends on the
- # queue size of this worker.
- tags :exclude_from_gitlab_com, :needs_own_queue
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/340629
+ tags :needs_own_queue
attr_reader :project_id
diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb
index 90e48f0e37a..d6a16b4d083 100644
--- a/app/workers/hashed_storage/rollbacker_worker.rb
+++ b/app/workers/hashed_storage/rollbacker_worker.rb
@@ -11,9 +11,8 @@ module HashedStorage
queue_namespace :hashed_storage
feature_category :source_code_management
- # Gitlab::HashedStorage::Migrator#rollback_pending? depends on the
- # queue size of this worker.
- tags :exclude_from_gitlab_com, :needs_own_queue
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/340629
+ tags :needs_own_queue
# @param [Integer] start initial ID of the batch
# @param [Integer] finish last ID of the batch
diff --git a/app/workers/incident_management/add_severity_system_note_worker.rb b/app/workers/incident_management/add_severity_system_note_worker.rb
index 31da7b0bcfe..3a4667bea0a 100644
--- a/app/workers/incident_management/add_severity_system_note_worker.rb
+++ b/app/workers/incident_management/add_severity_system_note_worker.rb
@@ -11,7 +11,6 @@ module IncidentManagement
queue_namespace :incident_management
feature_category :incident_management
- tags :exclude_from_kubernetes
def perform(incident_id, user_id)
return if incident_id.blank? || user_id.blank?
diff --git a/app/workers/issue_rebalancing_worker.rb b/app/workers/issue_rebalancing_worker.rb
index 13e02c37bdb..01984197aae 100644
--- a/app/workers/issue_rebalancing_worker.rb
+++ b/app/workers/issue_rebalancing_worker.rb
@@ -10,7 +10,6 @@ class IssueRebalancingWorker
idempotent!
urgency :low
feature_category :issue_tracking
- tags :exclude_from_kubernetes
deduplicate :until_executed, including_scheduled: true
def perform(ignore = nil, project_id = nil, root_namespace_id = nil)
@@ -33,12 +32,8 @@ class IssueRebalancingWorker
return
end
- # Temporary disable rebalancing for performance reasons
- # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321
- return if projects_to_rebalance.take&.root_namespace&.issue_repositioning_disabled? # rubocop:disable CodeReuse/ActiveRecord
-
- IssueRebalancingService.new(projects_to_rebalance).execute
- rescue IssueRebalancingService::TooManyIssues => e
+ Issues::RelativePositionRebalancingService.new(projects_to_rebalance).execute
+ rescue Issues::RelativePositionRebalancingService::TooManyConcurrentRebalances => e
Gitlab::ErrorTracking.log_exception(e, root_namespace_id: root_namespace_id, project_id: project_id)
end
diff --git a/app/workers/jira_connect/sync_builds_worker.rb b/app/workers/jira_connect/sync_builds_worker.rb
index 379f087bc1b..1a9974af55f 100644
--- a/app/workers/jira_connect/sync_builds_worker.rb
+++ b/app/workers/jira_connect/sync_builds_worker.rb
@@ -8,7 +8,6 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
data_consistency :delayed
- tags :exclude_from_kubernetes
urgency :low
worker_has_external_dependencies!
diff --git a/app/workers/jira_connect/sync_deployments_worker.rb b/app/workers/jira_connect/sync_deployments_worker.rb
index 3138230ced5..f4286752359 100644
--- a/app/workers/jira_connect/sync_deployments_worker.rb
+++ b/app/workers/jira_connect/sync_deployments_worker.rb
@@ -8,7 +8,6 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
data_consistency :delayed
- tags :exclude_from_kubernetes
urgency :low
worker_has_external_dependencies!
diff --git a/app/workers/jira_connect/sync_feature_flags_worker.rb b/app/workers/jira_connect/sync_feature_flags_worker.rb
index 4de27c1b551..507d5c29f38 100644
--- a/app/workers/jira_connect/sync_feature_flags_worker.rb
+++ b/app/workers/jira_connect/sync_feature_flags_worker.rb
@@ -8,7 +8,6 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
data_consistency :delayed
- tags :exclude_from_kubernetes
urgency :low
worker_has_external_dependencies!
diff --git a/app/workers/jira_connect/sync_project_worker.rb b/app/workers/jira_connect/sync_project_worker.rb
index a83444e58e7..b0ebaf30e99 100644
--- a/app/workers/jira_connect/sync_project_worker.rb
+++ b/app/workers/jira_connect/sync_project_worker.rb
@@ -8,7 +8,6 @@ module JiraConnect
queue_namespace :jira_connect
feature_category :integrations
data_consistency :delayed
- tags :exclude_from_kubernetes
urgency :low
worker_has_external_dependencies!
diff --git a/app/workers/member_invitation_reminder_emails_worker.rb b/app/workers/member_invitation_reminder_emails_worker.rb
index fa6787b9063..a7614db30f6 100644
--- a/app/workers/member_invitation_reminder_emails_worker.rb
+++ b/app/workers/member_invitation_reminder_emails_worker.rb
@@ -8,7 +8,6 @@ class MemberInvitationReminderEmailsWorker # rubocop:disable Scalability/Idempot
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :subgroups
- tags :exclude_from_kubernetes
urgency :low
def perform
diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb
index c57c6fbc28d..27bd5774b8d 100644
--- a/app/workers/merge_request_cleanup_refs_worker.rb
+++ b/app/workers/merge_request_cleanup_refs_worker.rb
@@ -10,7 +10,6 @@ class MergeRequestCleanupRefsWorker
sidekiq_options retry: 3
feature_category :code_review
- tags :exclude_from_kubernetes
idempotent!
# Hard-coded to 4 for now. Will be configurable later on via application settings.
diff --git a/app/workers/metrics/dashboard/sync_dashboards_worker.rb b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
index 645c03428a2..fe8694582c4 100644
--- a/app/workers/metrics/dashboard/sync_dashboards_worker.rb
+++ b/app/workers/metrics/dashboard/sync_dashboards_worker.rb
@@ -10,7 +10,6 @@ module Metrics
sidekiq_options retry: 3
feature_category :metrics
- tags :exclude_from_kubernetes
idempotent!
diff --git a/app/workers/namespaces/in_product_marketing_emails_worker.rb b/app/workers/namespaces/in_product_marketing_emails_worker.rb
index 035fa453f59..49e65d59e83 100644
--- a/app/workers/namespaces/in_product_marketing_emails_worker.rb
+++ b/app/workers/namespaces/in_product_marketing_emails_worker.rb
@@ -9,7 +9,6 @@ module Namespaces
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :subgroups
- tags :exclude_from_kubernetes
urgency :low
def perform
diff --git a/app/workers/namespaces/onboarding_issue_created_worker.rb b/app/workers/namespaces/onboarding_issue_created_worker.rb
index 3cff741ecbf..81d105ab19c 100644
--- a/app/workers/namespaces/onboarding_issue_created_worker.rb
+++ b/app/workers/namespaces/onboarding_issue_created_worker.rb
@@ -9,7 +9,6 @@ module Namespaces
sidekiq_options retry: 3
feature_category :issue_tracking
- tags :exclude_from_kubernetes
urgency :low
deduplicate :until_executing
diff --git a/app/workers/namespaces/onboarding_pipeline_created_worker.rb b/app/workers/namespaces/onboarding_pipeline_created_worker.rb
index 2c77fab8114..f9a6b734586 100644
--- a/app/workers/namespaces/onboarding_pipeline_created_worker.rb
+++ b/app/workers/namespaces/onboarding_pipeline_created_worker.rb
@@ -9,7 +9,6 @@ module Namespaces
sidekiq_options retry: 3
feature_category :subgroups
- tags :exclude_from_kubernetes
urgency :low
deduplicate :until_executing
diff --git a/app/workers/namespaces/onboarding_progress_worker.rb b/app/workers/namespaces/onboarding_progress_worker.rb
index 43d13618091..b77db1aec5e 100644
--- a/app/workers/namespaces/onboarding_progress_worker.rb
+++ b/app/workers/namespaces/onboarding_progress_worker.rb
@@ -10,7 +10,6 @@ module Namespaces
feature_category :product_analytics
worker_resource_boundary :cpu
- tags :exclude_from_kubernetes
urgency :low
deduplicate :until_executed
diff --git a/app/workers/namespaces/onboarding_user_added_worker.rb b/app/workers/namespaces/onboarding_user_added_worker.rb
index 4d4d9c03d3e..6a189e81b95 100644
--- a/app/workers/namespaces/onboarding_user_added_worker.rb
+++ b/app/workers/namespaces/onboarding_user_added_worker.rb
@@ -9,7 +9,6 @@ module Namespaces
sidekiq_options retry: 3
feature_category :users
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/packages/composer/cache_cleanup_worker.rb b/app/workers/packages/composer/cache_cleanup_worker.rb
index fee886bc750..19babf63967 100644
--- a/app/workers/packages/composer/cache_cleanup_worker.rb
+++ b/app/workers/packages/composer/cache_cleanup_worker.rb
@@ -10,7 +10,6 @@ module Packages
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :package_registry
- tags :exclude_from_kubernetes
idempotent!
diff --git a/app/workers/packages/composer/cache_update_worker.rb b/app/workers/packages/composer/cache_update_worker.rb
index f146a0a83cc..874993a1325 100644
--- a/app/workers/packages/composer/cache_update_worker.rb
+++ b/app/workers/packages/composer/cache_update_worker.rb
@@ -10,7 +10,6 @@ module Packages
sidekiq_options retry: 3
feature_category :package_registry
- tags :exclude_from_kubernetes
idempotent!
diff --git a/app/workers/packages/debian/process_changes_worker.rb b/app/workers/packages/debian/process_changes_worker.rb
index 3ddeb858429..0a716c61203 100644
--- a/app/workers/packages/debian/process_changes_worker.rb
+++ b/app/workers/packages/debian/process_changes_worker.rb
@@ -13,7 +13,6 @@ module Packages
queue_namespace :package_repositories
feature_category :package_registry
- tags :exclude_from_kubernetes
def perform(package_file_id, user_id)
@package_file_id = package_file_id
@@ -22,12 +21,7 @@ module Packages
return unless package_file && user
::Packages::Debian::ProcessChangesService.new(package_file, user).execute
- rescue ArgumentError,
- Packages::Debian::ExtractChangesMetadataService::ExtractionError,
- Packages::Debian::ExtractDebMetadataService::CommandFailedError,
- Packages::Debian::ExtractMetadataService::ExtractionError,
- Packages::Debian::ParseDebian822Service::InvalidDebian822Error,
- ActiveRecord::RecordNotFound => e
+ rescue StandardError => e
Gitlab::ErrorTracking.log_exception(e, package_file_id: @package_file_id, user_id: @user_id)
package_file.destroy!
end
diff --git a/app/workers/packages/go/sync_packages_worker.rb b/app/workers/packages/go/sync_packages_worker.rb
index 182c9bfec5d..dbf48cea1ae 100644
--- a/app/workers/packages/go/sync_packages_worker.rb
+++ b/app/workers/packages/go/sync_packages_worker.rb
@@ -12,7 +12,6 @@ module Packages
queue_namespace :package_repositories
feature_category :package_registry
- tags :exclude_from_kubernetes
deduplicate :until_executing
idempotent!
diff --git a/app/workers/packages/helm/extraction_worker.rb b/app/workers/packages/helm/extraction_worker.rb
index 1010a0833b1..0ba2d149f77 100644
--- a/app/workers/packages/helm/extraction_worker.rb
+++ b/app/workers/packages/helm/extraction_worker.rb
@@ -20,9 +20,7 @@ module Packages
::Packages::Helm::ProcessFileService.new(channel, package_file).execute
- rescue ::Packages::Helm::ExtractFileMetadataService::ExtractionError,
- ::Packages::Helm::ProcessFileService::ExtractionError,
- ::ActiveModel::ValidationError => e
+ rescue StandardError => e
Gitlab::ErrorTracking.log_exception(e, project_id: package_file.project_id)
package_file.package.update_column(:status, :error)
end
diff --git a/app/workers/packages/maven/metadata/sync_worker.rb b/app/workers/packages/maven/metadata/sync_worker.rb
index ab18c70e95e..b18b950e1c8 100644
--- a/app/workers/packages/maven/metadata/sync_worker.rb
+++ b/app/workers/packages/maven/metadata/sync_worker.rb
@@ -13,7 +13,6 @@ module Packages
queue_namespace :package_repositories
feature_category :package_registry
- tags :exclude_from_kubernetes
deduplicate :until_executing
idempotent!
diff --git a/app/workers/packages/rubygems/extraction_worker.rb b/app/workers/packages/rubygems/extraction_worker.rb
index 520305981cf..dbaf9bc35a9 100644
--- a/app/workers/packages/rubygems/extraction_worker.rb
+++ b/app/workers/packages/rubygems/extraction_worker.rb
@@ -11,7 +11,6 @@ module Packages
queue_namespace :package_repositories
feature_category :package_registry
- tags :exclude_from_kubernetes
deduplicate :until_executing
def perform(package_file_id)
diff --git a/app/workers/pages_domain_ssl_renewal_worker.rb b/app/workers/pages_domain_ssl_renewal_worker.rb
index d4c68f66699..d6b40318fd4 100644
--- a/app/workers/pages_domain_ssl_renewal_worker.rb
+++ b/app/workers/pages_domain_ssl_renewal_worker.rb
@@ -8,7 +8,6 @@ class PagesDomainSslRenewalWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
feature_category :pages
- tags :requires_disk_io, :exclude_from_kubernetes
def perform(domain_id)
domain = PagesDomain.find_by_id(domain_id)
diff --git a/app/workers/pages_domain_verification_worker.rb b/app/workers/pages_domain_verification_worker.rb
index f9504a7c1d2..59de00d40d5 100644
--- a/app/workers/pages_domain_verification_worker.rb
+++ b/app/workers/pages_domain_verification_worker.rb
@@ -8,7 +8,6 @@ class PagesDomainVerificationWorker # rubocop:disable Scalability/IdempotentWork
sidekiq_options retry: 3
feature_category :pages
- tags :requires_disk_io, :exclude_from_kubernetes
# rubocop: disable CodeReuse/ActiveRecord
def perform(domain_id)
diff --git a/app/workers/pages_remove_worker.rb b/app/workers/pages_remove_worker.rb
index 69a8344b5aa..4de99b8654d 100644
--- a/app/workers/pages_remove_worker.rb
+++ b/app/workers/pages_remove_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-# TODO: remove this worker https://gitlab.com/gitlab-org/gitlab/-/issues/320775
+# TODO: remove this worker https://gitlab.com/gitlab-org/gitlab/-/issues/340641
class PagesRemoveWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
@@ -8,13 +8,9 @@ class PagesRemoveWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
feature_category :pages
- tags :exclude_from_kubernetes
loggable_arguments 0
def perform(project_id)
- project = Project.find_by_id(project_id)
- return unless project
-
- project.legacy_remove_pages
+ # no-op
end
end
diff --git a/app/workers/pages_transfer_worker.rb b/app/workers/pages_transfer_worker.rb
index c2190a352dd..404c79b9e89 100644
--- a/app/workers/pages_transfer_worker.rb
+++ b/app/workers/pages_transfer_worker.rb
@@ -10,7 +10,6 @@ class PagesTransferWorker # rubocop:disable Scalability/IdempotentWorker
TransferFailedError = Class.new(StandardError)
feature_category :pages
- tags :exclude_from_kubernetes
loggable_arguments 0, 1
def perform(method, args)
diff --git a/app/workers/pages_update_configuration_worker.rb b/app/workers/pages_update_configuration_worker.rb
index 8a37b70a0b7..3dfd82ed517 100644
--- a/app/workers/pages_update_configuration_worker.rb
+++ b/app/workers/pages_update_configuration_worker.rb
@@ -9,7 +9,6 @@ class PagesUpdateConfigurationWorker
idempotent!
feature_category :pages
- tags :exclude_from_kubernetes
def self.perform_async(*args)
return unless ::Settings.pages.local_store.enabled
diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb
index d0c21cf74e1..5e951ab2c3a 100644
--- a/app/workers/pages_worker.rb
+++ b/app/workers/pages_worker.rb
@@ -8,7 +8,6 @@ class PagesWorker # rubocop:disable Scalability/IdempotentWorker
sidekiq_options retry: 3
feature_category :pages
loggable_arguments 0, 1
- tags :requires_disk_io, :exclude_from_kubernetes
worker_resource_boundary :cpu
def perform(action, *arg)
diff --git a/app/workers/personal_access_tokens/expired_notification_worker.rb b/app/workers/personal_access_tokens/expired_notification_worker.rb
index 8deacf457b2..2d0ea3d3aa4 100644
--- a/app/workers/personal_access_tokens/expired_notification_worker.rb
+++ b/app/workers/personal_access_tokens/expired_notification_worker.rb
@@ -9,7 +9,6 @@ module PersonalAccessTokens
include CronjobQueue
feature_category :authentication_and_authorization
- tags :exclude_from_kubernetes
def perform(*args)
notification_service = NotificationService.new
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 4a49e18eb9b..7d0322361b8 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -1,8 +1,10 @@
# frozen_string_literal: true
-class PostReceive # rubocop:disable Scalability/IdempotentWorker
+class PostReceive
include ApplicationWorker
+ idempotent!
+ deduplicate :none
data_consistency :always
sidekiq_options retry: 3
diff --git a/app/workers/project_destroy_worker.rb b/app/workers/project_destroy_worker.rb
index 149f8290b54..45d0ebd2b65 100644
--- a/app/workers/project_destroy_worker.rb
+++ b/app/workers/project_destroy_worker.rb
@@ -9,7 +9,6 @@ class ProjectDestroyWorker # rubocop:disable Scalability/IdempotentWorker
include ExceptionBacktrace
feature_category :source_code_management
- tags :requires_disk_io, :exclude_from_kubernetes
def perform(project_id, user_id, params)
project = Project.find(project_id)
diff --git a/app/workers/projects/git_garbage_collect_worker.rb b/app/workers/projects/git_garbage_collect_worker.rb
index 0d67a8ac30e..cf236f8b660 100644
--- a/app/workers/projects/git_garbage_collect_worker.rb
+++ b/app/workers/projects/git_garbage_collect_worker.rb
@@ -5,8 +5,6 @@ module Projects
extend ::Gitlab::Utils::Override
include GitGarbageCollectMethods
- tags :exclude_from_kubernetes
-
private
override :find_resource
diff --git a/app/workers/projects/post_creation_worker.rb b/app/workers/projects/post_creation_worker.rb
index 99438e4e4b2..3a39bd17ce3 100644
--- a/app/workers/projects/post_creation_worker.rb
+++ b/app/workers/projects/post_creation_worker.rb
@@ -9,7 +9,6 @@ module Projects
sidekiq_options retry: 3
feature_category :source_code_management
- tags :exclude_from_kubernetes
idempotent!
def perform(project_id)
diff --git a/app/workers/propagate_integration_group_worker.rb b/app/workers/propagate_integration_group_worker.rb
index 443ff1f2abe..ed08e90f38d 100644
--- a/app/workers/propagate_integration_group_worker.rb
+++ b/app/workers/propagate_integration_group_worker.rb
@@ -6,7 +6,6 @@ class PropagateIntegrationGroupWorker
data_consistency :always
sidekiq_options retry: 3
feature_category :integrations
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/propagate_integration_inherit_descendant_worker.rb b/app/workers/propagate_integration_inherit_descendant_worker.rb
index 24573591409..8b3ecc1f057 100644
--- a/app/workers/propagate_integration_inherit_descendant_worker.rb
+++ b/app/workers/propagate_integration_inherit_descendant_worker.rb
@@ -6,7 +6,6 @@ class PropagateIntegrationInheritDescendantWorker
data_consistency :always
sidekiq_options retry: 3
feature_category :integrations
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/propagate_integration_inherit_worker.rb b/app/workers/propagate_integration_inherit_worker.rb
index 24a8778b928..f0a53f8cb07 100644
--- a/app/workers/propagate_integration_inherit_worker.rb
+++ b/app/workers/propagate_integration_inherit_worker.rb
@@ -6,7 +6,6 @@ class PropagateIntegrationInheritWorker
data_consistency :always
sidekiq_options retry: 3
feature_category :integrations
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/propagate_integration_project_worker.rb b/app/workers/propagate_integration_project_worker.rb
index dba8a270007..bc55b7ff504 100644
--- a/app/workers/propagate_integration_project_worker.rb
+++ b/app/workers/propagate_integration_project_worker.rb
@@ -6,7 +6,6 @@ class PropagateIntegrationProjectWorker
data_consistency :always
sidekiq_options retry: 3
feature_category :integrations
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/purge_dependency_proxy_cache_worker.rb b/app/workers/purge_dependency_proxy_cache_worker.rb
index 8ab4e77bc78..db43e4adf20 100644
--- a/app/workers/purge_dependency_proxy_cache_worker.rb
+++ b/app/workers/purge_dependency_proxy_cache_worker.rb
@@ -27,6 +27,6 @@ class PurgeDependencyProxyCacheWorker
def valid?
return unless @group
- can?(@current_user, :admin_group, @group) && @group.dependency_proxy_feature_available?
+ can?(@current_user, :admin_group, @group)
end
end
diff --git a/app/workers/releases/create_evidence_worker.rb b/app/workers/releases/create_evidence_worker.rb
index 5aed543500f..628b0e0b806 100644
--- a/app/workers/releases/create_evidence_worker.rb
+++ b/app/workers/releases/create_evidence_worker.rb
@@ -9,7 +9,6 @@ module Releases
sidekiq_options retry: 3
feature_category :release_evidence
- tags :exclude_from_kubernetes
# pipeline_id is optional for backward compatibility with existing jobs
# caller should always try to provide the pipeline and pass nil only
diff --git a/app/workers/releases/manage_evidence_worker.rb b/app/workers/releases/manage_evidence_worker.rb
index f316aa6eefd..94d0342a65a 100644
--- a/app/workers/releases/manage_evidence_worker.rb
+++ b/app/workers/releases/manage_evidence_worker.rb
@@ -9,7 +9,6 @@ module Releases
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :release_evidence
- tags :exclude_from_kubernetes
def perform
releases = Release.without_evidence.released_within_2hrs
diff --git a/app/workers/remove_unaccepted_member_invites_worker.rb b/app/workers/remove_unaccepted_member_invites_worker.rb
index 7833ec30c3c..7fe45b26094 100644
--- a/app/workers/remove_unaccepted_member_invites_worker.rb
+++ b/app/workers/remove_unaccepted_member_invites_worker.rb
@@ -8,7 +8,6 @@ class RemoveUnacceptedMemberInvitesWorker # rubocop:disable Scalability/Idempote
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :authentication_and_authorization
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
index 46a6e0ef01f..58cd8f7ade3 100644
--- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb
+++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb
@@ -8,7 +8,6 @@ class ScheduleMergeRequestCleanupRefsWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext
feature_category :code_review
- tags :exclude_from_kubernetes
idempotent!
def perform
diff --git a/app/workers/service_desk_email_receiver_worker.rb b/app/workers/service_desk_email_receiver_worker.rb
index f546fce3e8a..c8ab8891856 100644
--- a/app/workers/service_desk_email_receiver_worker.rb
+++ b/app/workers/service_desk_email_receiver_worker.rb
@@ -6,9 +6,10 @@ class ServiceDeskEmailReceiverWorker < EmailReceiverWorker # rubocop:disable Sca
data_consistency :always
feature_category :service_desk
+ urgency :high
sidekiq_options retry: 3
- # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1087#jobs-written-to-redis-without-passing-through-the-application
+ # https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1263
tags :needs_own_queue
def should_perform?
diff --git a/app/workers/ssh_keys/expired_notification_worker.rb b/app/workers/ssh_keys/expired_notification_worker.rb
index 6afeecdd1b5..d8553b5a9a2 100644
--- a/app/workers/ssh_keys/expired_notification_worker.rb
+++ b/app/workers/ssh_keys/expired_notification_worker.rb
@@ -9,7 +9,6 @@ module SshKeys
include CronjobQueue
feature_category :compliance_management
- tags :exclude_from_kubernetes
idempotent!
BATCH_SIZE = 500
diff --git a/app/workers/ssh_keys/expiring_soon_notification_worker.rb b/app/workers/ssh_keys/expiring_soon_notification_worker.rb
index ef256621e07..a89520867ed 100644
--- a/app/workers/ssh_keys/expiring_soon_notification_worker.rb
+++ b/app/workers/ssh_keys/expiring_soon_notification_worker.rb
@@ -9,7 +9,6 @@ module SshKeys
include CronjobQueue
feature_category :compliance_management
- tags :exclude_from_kubernetes
idempotent!
def perform
diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb
index 5723380a3f3..a2b2686c8d5 100644
--- a/app/workers/stuck_ci_jobs_worker.rb
+++ b/app/workers/stuck_ci_jobs_worker.rb
@@ -3,59 +3,29 @@
class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
- data_consistency :always
-
+ # rubocop:disable Scalability/CronWorkerContext
+ # This is an instance-wide cleanup query, so there's no meaningful
+ # scope to consider this in the context of.
include CronjobQueue
+ # rubocop:enable Scalability/CronWorkerContext
+
+ data_consistency :always
feature_category :continuous_integration
worker_resource_boundary :cpu
EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease'
- BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour
- BUILD_PENDING_OUTDATED_TIMEOUT = 1.day
- BUILD_SCHEDULED_OUTDATED_TIMEOUT = 1.hour
- BUILD_PENDING_STUCK_TIMEOUT = 1.hour
- BUILD_LOOKBACK = 5.days
-
def perform
return unless try_obtain_lease
- Gitlab::AppLogger.info "#{self.class}: Cleaning stuck builds"
-
- drop(running_timed_out_builds, failure_reason: :stuck_or_timeout_failure)
-
- drop(
- Ci::Build.pending.updated_before(lookback: BUILD_LOOKBACK.ago, timeout: BUILD_PENDING_OUTDATED_TIMEOUT.ago),
- failure_reason: :stuck_or_timeout_failure
- )
-
- drop(scheduled_timed_out_builds, failure_reason: :stale_schedule)
-
- drop_stuck(
- Ci::Build.pending.updated_before(lookback: BUILD_LOOKBACK.ago, timeout: BUILD_PENDING_STUCK_TIMEOUT.ago),
- failure_reason: :stuck_or_timeout_failure
- )
+ Ci::StuckBuilds::DropService.new.execute
remove_lease
end
private
- def scheduled_timed_out_builds
- Ci::Build.where(status: :scheduled).where( # rubocop: disable CodeReuse/ActiveRecord
- 'ci_builds.scheduled_at IS NOT NULL AND ci_builds.scheduled_at < ?',
- BUILD_SCHEDULED_OUTDATED_TIMEOUT.ago
- )
- end
-
- def running_timed_out_builds
- Ci::Build.running.where( # rubocop: disable CodeReuse/ActiveRecord
- 'ci_builds.updated_at < ?',
- BUILD_RUNNING_OUTDATED_TIMEOUT.ago
- )
- end
-
def try_obtain_lease
@uuid = Gitlab::ExclusiveLease.new(EXCLUSIVE_LEASE_KEY, timeout: 30.minutes).try_obtain
end
@@ -63,55 +33,4 @@ class StuckCiJobsWorker # rubocop:disable Scalability/IdempotentWorker
def remove_lease
Gitlab::ExclusiveLease.cancel(EXCLUSIVE_LEASE_KEY, @uuid)
end
-
- def drop(builds, failure_reason:)
- fetch(builds) do |build|
- drop_build :outdated, build, failure_reason
- end
- end
-
- def drop_stuck(builds, failure_reason:)
- fetch(builds) do |build|
- break unless build.stuck?
-
- drop_build :stuck, build, failure_reason
- end
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def fetch(builds)
- loop do
- jobs = builds.includes(:tags, :runner, project: [:namespace, :route])
- .limit(100)
- .to_a
-
- break if jobs.empty?
-
- jobs.each do |job|
- with_context(project: job.project) { yield(job) }
- end
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def drop_build(type, build, reason)
- Gitlab::AppLogger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{build.status}, failure_reason: #{reason})"
- Gitlab::OptimisticLocking.retry_lock(build, 3, name: 'stuck_ci_jobs_worker_drop_build') do |b|
- b.drop(reason)
- end
- rescue StandardError => ex
- build.doom!
-
- track_exception_for_build(ex, build)
- end
-
- def track_exception_for_build(ex, build)
- Gitlab::ErrorTracking.track_exception(ex,
- build_id: build.id,
- build_name: build.name,
- build_stage: build.stage,
- pipeline_id: build.pipeline_id,
- project_id: build.project_id
- )
- end
end
diff --git a/app/workers/todos_destroyer/destroyed_designs_worker.rb b/app/workers/todos_destroyer/destroyed_designs_worker.rb
new file mode 100644
index 00000000000..e01c1a109d7
--- /dev/null
+++ b/app/workers/todos_destroyer/destroyed_designs_worker.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module TodosDestroyer
+ class DestroyedDesignsWorker
+ include ApplicationWorker
+
+ data_consistency :always
+
+ sidekiq_options retry: 3
+ include TodosDestroyerQueue
+
+ idempotent!
+
+ def perform(design_ids)
+ ::Todos::Destroy::DesignService.new(design_ids).execute
+ end
+ end
+end
diff --git a/app/workers/todos_destroyer/destroyed_issuable_worker.rb b/app/workers/todos_destroyer/destroyed_issuable_worker.rb
index ff4f5e15472..f93c1389534 100644
--- a/app/workers/todos_destroyer/destroyed_issuable_worker.rb
+++ b/app/workers/todos_destroyer/destroyed_issuable_worker.rb
@@ -9,8 +9,6 @@ module TodosDestroyer
sidekiq_options retry: 3
include TodosDestroyerQueue
- tags :exclude_from_kubernetes
-
idempotent!
def perform(target_id, target_type)
diff --git a/app/workers/user_status_cleanup/batch_worker.rb b/app/workers/user_status_cleanup/batch_worker.rb
index b6ca6548572..f3d73b2e6e9 100644
--- a/app/workers/user_status_cleanup/batch_worker.rb
+++ b/app/workers/user_status_cleanup/batch_worker.rb
@@ -12,7 +12,6 @@ module UserStatusCleanup
# rubocop:enable Scalability/CronWorkerContext
feature_category :users
- tags :exclude_from_kubernetes
idempotent!
diff --git a/app/workers/users/deactivate_dormant_users_worker.rb b/app/workers/users/deactivate_dormant_users_worker.rb
index bcb13483379..d7ea20e4b62 100644
--- a/app/workers/users/deactivate_dormant_users_worker.rb
+++ b/app/workers/users/deactivate_dormant_users_worker.rb
@@ -9,7 +9,6 @@ module Users
include CronjobQueue
feature_category :utilization
- tags :exclude_from_kubernetes
NUMBER_OF_BATCHES = 50
BATCH_SIZE = 200
diff --git a/app/workers/web_hooks/destroy_worker.rb b/app/workers/web_hooks/destroy_worker.rb
index b92fe86bafb..f457cd11e54 100644
--- a/app/workers/web_hooks/destroy_worker.rb
+++ b/app/workers/web_hooks/destroy_worker.rb
@@ -7,7 +7,6 @@ module WebHooks
data_consistency :always
sidekiq_options retry: 3
feature_category :integrations
- tags :exclude_from_kubernetes
urgency :low
idempotent!
diff --git a/app/workers/web_hooks/log_execution_worker.rb b/app/workers/web_hooks/log_execution_worker.rb
index 50d91182c80..280d987fa77 100644
--- a/app/workers/web_hooks/log_execution_worker.rb
+++ b/app/workers/web_hooks/log_execution_worker.rb
@@ -7,6 +7,8 @@ module WebHooks
data_consistency :always
feature_category :integrations
urgency :low
+ sidekiq_options retry: 3
+ loggable_arguments 0, 2, 3
idempotent!
diff --git a/app/workers/wikis/git_garbage_collect_worker.rb b/app/workers/wikis/git_garbage_collect_worker.rb
index f34d3be51d2..1b455c50618 100644
--- a/app/workers/wikis/git_garbage_collect_worker.rb
+++ b/app/workers/wikis/git_garbage_collect_worker.rb
@@ -5,8 +5,6 @@ module Wikis
extend ::Gitlab::Utils::Override
include GitGarbageCollectMethods
- tags :exclude_from_kubernetes
-
private
override :find_resource